Compare commits
197 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 378c230815 | |||
| eb39a4ac00 | |||
| 59290b23b1 | |||
| 9a08c40136 | |||
| 9ced2fe212 | |||
| 3fb393166f | |||
| 383e786479 | |||
| 30848e6fe8 | |||
| 318dc84dc5 | |||
| 5a787e0134 | |||
| 47150d0ab3 | |||
| 9d392d8d6a | |||
| cff29232c7 | |||
| d8247843c4 | |||
| 7863935fff | |||
| 2775847c6d | |||
| a1b9a5e46d | |||
| cdbccc23a8 | |||
| 1aae7438bb | |||
|
|
bb150ce42a | ||
|
|
c50b3c3c69 | ||
|
|
a6d7ae6916 | ||
|
|
96b3eca604 | ||
|
|
70de25b29b | ||
|
|
1db8b95ba4 | ||
|
|
fcd7a304e8 | ||
|
|
dc444810b1 | ||
|
|
0937a1c5ad | ||
|
|
0e26aefeb3 | ||
|
|
cc772a7c90 | ||
|
|
2d65aeec29 | ||
|
|
9888985deb | ||
|
|
11cd05e6a8 | ||
|
|
40733756ab | ||
|
|
a21cf192eb | ||
|
|
601088b5c6 | ||
|
|
c939dc2790 | ||
|
|
36aa5e7368 | ||
|
|
14591b3bf8 | ||
|
|
880a9c92aa | ||
|
|
2a203aa0d1 | ||
|
|
b2a32d178f | ||
|
|
abb6cbc446 | ||
|
|
98c3a031bc | ||
|
|
fb3508f43e | ||
|
|
65974ef954 | ||
|
|
e2dff06f8d | ||
|
|
2cdd3e9fda | ||
|
|
03e4198fdd | ||
|
|
64c748d0b6 | ||
|
|
66932c5d0b | ||
|
|
2511ae93fd | ||
|
|
b8648426f8 | ||
|
|
ef1b1ded5d | ||
|
|
6c969b0d97 | ||
|
|
d19ac38d6b | ||
|
|
f2b932bc93 | ||
|
|
8883af90f0 | ||
|
|
7f4abdefea | ||
|
|
17bbb20b94 | ||
|
|
f5718e3656 | ||
|
|
29c4f74ef5 | ||
|
|
a3c9ca88f8 | ||
|
|
d2b4f0f39e | ||
|
|
e24491639d | ||
|
|
c27a37d52f | ||
|
|
2b81f2cb31 | ||
|
|
7772c4d9f5 | ||
|
|
092cbc3cbb | ||
|
|
11d4b30a09 | ||
|
|
c58c9a9a25 | ||
|
|
5be231da73 | ||
|
|
1dab572280 | ||
|
|
872362d5a6 | ||
|
|
6ede000c66 | ||
|
|
f07fe621bc | ||
|
|
820e563516 | ||
|
|
eb91146f63 | ||
|
|
9cec89a67c | ||
|
|
929d525c13 | ||
|
|
7a9af238b6 | ||
|
|
779572a864 | ||
|
|
02f4cd417b | ||
|
|
21cb79d2c2 | ||
|
|
4c696745b0 | ||
|
|
89f8859ba3 | ||
|
|
fd4da6a393 | ||
|
|
26d26a9554 | ||
|
|
ed59896c5f | ||
|
|
d28a3279e4 | ||
|
|
9e81b2d80a | ||
|
|
04e5479acd | ||
|
|
aad6b50d81 | ||
|
|
3b3cd92026 | ||
|
|
4ff331ec98 | ||
|
|
d500119219 | ||
|
|
93c7b5b637 | ||
|
|
09b3b3543b | ||
|
|
6b765e4246 | ||
|
|
2e9d0bd16a | ||
|
|
96bce87a0e | ||
|
|
59f5d2cb81 | ||
|
|
10e924b4ab | ||
|
|
3a50e06c63 | ||
|
|
1902761945 | ||
|
|
a689991a43 | ||
|
|
246e1314aa | ||
|
|
374cb23111 | ||
|
|
a2e9701434 | ||
|
|
f209d73352 | ||
|
|
862839e44a | ||
|
|
62d698a99b | ||
|
|
f19da4151c | ||
|
|
9119a69b17 | ||
|
|
f9302b0d82 | ||
|
|
06baaafe26 | ||
|
|
467dca9b86 | ||
|
|
bc0726aaa6 | ||
|
|
68a6c57cf2 | ||
|
|
a0b46eaf3d | ||
|
|
a3b8ac3237 | ||
|
|
93cd39c99f | ||
|
|
ed80799cae | ||
|
|
baebafea59 | ||
|
|
e58c9875e0 | ||
|
|
54f0385be4 | ||
|
|
da2eeee518 | ||
|
|
60f2a57abf | ||
|
|
7267e1d1f8 | ||
|
|
9306d9c48d | ||
|
|
260348736d | ||
|
|
f383c447f5 | ||
|
|
5963a0ab3c | ||
|
|
9aea491966 | ||
|
|
2bd5df4358 | ||
|
|
549a93b495 | ||
|
|
bde322fd09 | ||
|
|
ba7f68af1e | ||
|
|
487700f5b7 | ||
|
|
50c9bb2387 | ||
|
|
57671967c9 | ||
|
|
82ab13b517 | ||
|
|
bfc5e0e19b | ||
|
|
86927b4cfc | ||
|
|
fe48e5f156 | ||
|
|
71efc3cfa5 | ||
|
|
c378b9b2fd | ||
|
|
d296a20aef | ||
|
|
7a3f78bc7a | ||
|
|
a43e453e7e | ||
|
|
4466897d0a | ||
|
|
6bf1af56a5 | ||
|
|
c0362b975f | ||
|
|
4d0b75a1c5 | ||
|
|
aaad50c10d | ||
|
|
04e076747b | ||
|
|
08913c1e35 | ||
|
|
4a9678bb15 | ||
|
|
bd194e91c2 | ||
|
|
fc56c3347a | ||
|
|
1c712659f1 | ||
|
|
cfc0e6187c | ||
|
|
a996c51ae3 | ||
|
|
23fee7bd4e | ||
|
|
359509ec52 | ||
|
|
8b54b3ba1b | ||
|
|
1ebf6f32e0 | ||
|
|
69a7bcba92 | ||
|
|
0bdf9ab290 | ||
|
|
f3f5629b75 | ||
|
|
ba28e09e58 | ||
|
|
ef846a4596 | ||
|
|
dcd4aa61f7 | ||
|
|
4ea878a7f0 | ||
|
|
19641313bd | ||
|
|
5f3b97b8f8 | ||
|
|
a306edd698 | ||
|
|
8f4ebde155 | ||
|
|
b3af20d89a | ||
|
|
6fb0c75f38 | ||
|
|
6ad1361e30 | ||
|
|
25acfe9047 | ||
|
|
856f8aebd2 | ||
|
|
b0d2b11622 | ||
|
|
360358acaa | ||
|
|
d43fa5ffa2 | ||
|
|
224be878c6 | ||
|
|
0fb00d7ec1 | ||
|
|
c4a10af925 | ||
|
|
59967b059c | ||
|
|
5870cbd6b4 | ||
|
|
8ad4926665 | ||
|
|
402ad361e5 | ||
|
|
b925b5722a | ||
|
|
efb4595875 | ||
|
|
01b664c050 | ||
|
|
6a0342b652 |
313 changed files with 46071 additions and 11089 deletions
5
.claude/CLAUDE.md
Normal file
5
.claude/CLAUDE.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
@../AGENTS.md
|
||||||
12
.cursor/mcp.json
Normal file
12
.cursor/mcp.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"dart": {
|
||||||
|
"command": "dart",
|
||||||
|
"args": ["mcp-server", "--force-roots-fallback"]
|
||||||
|
},
|
||||||
|
"next-devtools": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["web/node_modules/next-devtools-mcp/dist/index.js"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
.env.example
Normal file
2
.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
CLIENT_ID=
|
||||||
|
SECRET=
|
||||||
14
.forgejo/workflows/actions-probe.yml
Normal file
14
.forgejo/workflows/actions-probe.yml
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Temporary probe: does this repo schedule Actions at all?
|
||||||
|
name: Actions probe
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
probe:
|
||||||
|
runs-on: desktop-release
|
||||||
|
steps:
|
||||||
|
- name: ping
|
||||||
|
run: echo "frosty actions probe ok"
|
||||||
304
.forgejo/workflows/release.yml
Normal file
304
.forgejo/workflows/release.yml
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
# Builds a signed Frosty release APK and publishes a Forgejo release for Obtainium.
|
||||||
|
# Pattern: finamp-fork / jellyfin-android-fork on host runner `desktop-release`.
|
||||||
|
#
|
||||||
|
# Required repository secrets:
|
||||||
|
# RELEASE_KEYSTORE_BASE64, STORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD
|
||||||
|
# CLIENT_ID, SECRET (Twitch Helix app credentials — --dart-define)
|
||||||
|
#
|
||||||
|
# Obtainium:
|
||||||
|
# Source: Forgejo
|
||||||
|
# URL: https://forgejo.fsociety00.cc/api/v1/repos/copilot/frosty/releases
|
||||||
|
# APK filter: frosty-arm64-v8a-v.*\.apk
|
||||||
|
# Trim version: v?([0-9]+\.[0-9]+\.[0-9]+)
|
||||||
|
# Match group: 1
|
||||||
|
# Private repo → Forgejo API token in Obtainium.
|
||||||
|
|
||||||
|
name: Release APK
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: frosty-release
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-release:
|
||||||
|
name: Build & publish release
|
||||||
|
runs-on: desktop-release
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Clone repository
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf .git
|
||||||
|
git init .
|
||||||
|
origin_url="${GITHUB_SERVER_URL#https://}"
|
||||||
|
origin_url="${origin_url#http://}"
|
||||||
|
git remote add origin "https://copilot:${FORGEJO_TOKEN}@${origin_url}/${GITHUB_REPOSITORY}.git"
|
||||||
|
git -c http.sslVerify=false fetch --depth=1 origin "${GITHUB_SHA}"
|
||||||
|
git checkout --force FETCH_HEAD
|
||||||
|
test -f pubspec.yaml
|
||||||
|
test -f android/app/build.gradle.kts
|
||||||
|
echo "Checked out ${GITHUB_REPOSITORY}@${GITHUB_SHA}"
|
||||||
|
|
||||||
|
- name: Set up JDK
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
pkg_install() {
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then "$@"
|
||||||
|
elif command -v sudo >/dev/null 2>&1; then sudo "$@"
|
||||||
|
else "$@"; fi
|
||||||
|
}
|
||||||
|
if ! command -v java >/dev/null 2>&1; then
|
||||||
|
if command -v dnf >/dev/null 2>&1; then pkg_install dnf install -y java-21-openjdk-devel
|
||||||
|
elif command -v apt-get >/dev/null 2>&1; then
|
||||||
|
pkg_install apt-get update
|
||||||
|
pkg_install apt-get install -y openjdk-21-jdk
|
||||||
|
elif command -v apk >/dev/null 2>&1; then pkg_install apk add --no-cache openjdk21
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
java -version
|
||||||
|
if command -v javac >/dev/null 2>&1; then
|
||||||
|
java_home="$(dirname "$(dirname "$(readlink -f "$(command -v javac)")")")"
|
||||||
|
echo "JAVA_HOME=$java_home" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Set up Android SDK
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
sdk_candidates=(
|
||||||
|
"${ANDROID_HOME:-}"
|
||||||
|
"${ANDROID_SDK_ROOT:-}"
|
||||||
|
"/home/yuna/Android/Sdk"
|
||||||
|
"${HOME}/Android/Sdk"
|
||||||
|
"/opt/android-sdk-linux"
|
||||||
|
"/root/Android/Sdk"
|
||||||
|
)
|
||||||
|
sdk_dir=""
|
||||||
|
for candidate in "${sdk_candidates[@]}"; do
|
||||||
|
if [ -n "$candidate" ] && [ -d "$candidate/platforms" ] && [ -d "$candidate/build-tools" ]; then
|
||||||
|
sdk_dir="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -z "$sdk_dir" ]; then
|
||||||
|
echo "Android SDK not found on desktop-release host."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "ANDROID_HOME=$sdk_dir" >> "$GITHUB_ENV"
|
||||||
|
echo "ANDROID_SDK_ROOT=$sdk_dir" >> "$GITHUB_ENV"
|
||||||
|
printf 'sdk.dir=%s\n' "$sdk_dir" > android/local.properties
|
||||||
|
echo "Using Android SDK: $sdk_dir"
|
||||||
|
|
||||||
|
- name: Set up Flutter
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
pkg_install() {
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then "$@"
|
||||||
|
elif command -v sudo >/dev/null 2>&1; then sudo "$@"
|
||||||
|
else "$@"; fi
|
||||||
|
}
|
||||||
|
if ! command -v curl >/dev/null 2>&1; then
|
||||||
|
if command -v apk >/dev/null 2>&1; then pkg_install apk add --no-cache curl bash git unzip xz
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then pkg_install dnf install -y curl git unzip
|
||||||
|
elif command -v apt-get >/dev/null 2>&1; then
|
||||||
|
pkg_install apt-get update
|
||||||
|
pkg_install apt-get install -y curl git unzip
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
# Prefer pinned SDK already on the desktop-release host.
|
||||||
|
flutter_version="$(awk '/^[[:space:]]*flutter:/{print $2; exit}' pubspec.yaml)"
|
||||||
|
flutter_version="${flutter_version:-3.47.0}"
|
||||||
|
candidates=(
|
||||||
|
"/home/yuna/.local/flutter-${flutter_version}"
|
||||||
|
"${HOME}/.cache/frosty-ci-flutter-${flutter_version}"
|
||||||
|
"/home/yuna/.local/flutter"
|
||||||
|
)
|
||||||
|
flutter_home=""
|
||||||
|
for c in "${candidates[@]}"; do
|
||||||
|
if [ -x "${c}/bin/flutter" ]; then
|
||||||
|
flutter_home="$c"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -z "$flutter_home" ]; then
|
||||||
|
flutter_home="${HOME}/.cache/frosty-ci-flutter-${flutter_version}"
|
||||||
|
rm -rf "${flutter_home}"
|
||||||
|
git clone --depth 1 --branch "${flutter_version}" https://github.com/flutter/flutter.git "${flutter_home}"
|
||||||
|
fi
|
||||||
|
echo "${flutter_home}/bin" >> "$GITHUB_PATH"
|
||||||
|
echo "PATH=${flutter_home}/bin:${PATH}" >> "$GITHUB_ENV"
|
||||||
|
export PATH="${flutter_home}/bin:${PATH}"
|
||||||
|
flutter --version
|
||||||
|
flutter precache --android
|
||||||
|
|
||||||
|
- name: Decode keystore
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
|
||||||
|
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||||
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${RELEASE_KEYSTORE_BASE64:-}" ] || [ -z "${STORE_PASSWORD:-}" ] || [ -z "${KEY_ALIAS:-}" ] || [ -z "${KEY_PASSWORD:-}" ]; then
|
||||||
|
echo "Missing signing secrets: RELEASE_KEYSTORE_BASE64, STORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '%s' "$RELEASE_KEYSTORE_BASE64" | base64 -d > android/upload-keystore.jks
|
||||||
|
test -s android/upload-keystore.jks
|
||||||
|
{
|
||||||
|
echo "storePassword=${STORE_PASSWORD}"
|
||||||
|
echo "keyPassword=${KEY_PASSWORD}"
|
||||||
|
echo "keyAlias=${KEY_ALIAS}"
|
||||||
|
echo "storeFile=${GITHUB_WORKSPACE}/android/upload-keystore.jks"
|
||||||
|
} > android/key.properties
|
||||||
|
echo "Keystore ready ($(wc -c < android/upload-keystore.jks) bytes)."
|
||||||
|
|
||||||
|
- name: Resolve version metadata
|
||||||
|
id: version
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
raw="$(grep -E '^version:' pubspec.yaml | head -1 | awk '{print $2}')"
|
||||||
|
VERSION_NAME="${raw%%+*}"
|
||||||
|
VERSION_CODE="${raw##*+}"
|
||||||
|
[ "$VERSION_CODE" != "$raw" ] || VERSION_CODE=1
|
||||||
|
SHORT_SHA="${GITHUB_SHA::7}"
|
||||||
|
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||||
|
TAG="${GITHUB_REF#refs/tags/}"
|
||||||
|
else
|
||||||
|
TAG="build-${VERSION_NAME}-${VERSION_CODE}-${SHORT_SHA}"
|
||||||
|
fi
|
||||||
|
echo "version_name=${VERSION_NAME}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version_code=${VERSION_CODE}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=${TAG} version=${VERSION_NAME}+${VERSION_CODE}"
|
||||||
|
|
||||||
|
- name: Build signed release APK
|
||||||
|
id: build
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
CLIENT_ID: ${{ secrets.CLIENT_ID }}
|
||||||
|
SECRET: ${{ secrets.SECRET }}
|
||||||
|
VERSION_NAME: ${{ steps.version.outputs.version_name }}
|
||||||
|
VERSION_CODE: ${{ steps.version.outputs.version_code }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${CLIENT_ID:-}" ] || [ -z "${SECRET:-}" ]; then
|
||||||
|
echo "Missing Twitch secrets CLIENT_ID / SECRET (Helix OAuth app)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
flutter pub get
|
||||||
|
flutter build apk --release \
|
||||||
|
--build-name="${VERSION_NAME}" \
|
||||||
|
--build-number="${VERSION_CODE}" \
|
||||||
|
--dart-define="CLIENT_ID=${CLIENT_ID}" \
|
||||||
|
--dart-define="SECRET=${SECRET}"
|
||||||
|
mkdir -p dist
|
||||||
|
cp build/app/outputs/flutter-apk/app-release.apk \
|
||||||
|
"dist/frosty-v${VERSION_NAME}.apk"
|
||||||
|
flutter build apk --release --split-per-abi \
|
||||||
|
--build-name="${VERSION_NAME}" \
|
||||||
|
--build-number="${VERSION_CODE}" \
|
||||||
|
--dart-define="CLIENT_ID=${CLIENT_ID}" \
|
||||||
|
--dart-define="SECRET=${SECRET}" || true
|
||||||
|
for abi in arm64-v8a armeabi-v7a x86_64; do
|
||||||
|
src="build/app/outputs/flutter-apk/app-${abi}-release.apk"
|
||||||
|
if [ -f "$src" ]; then
|
||||||
|
cp "$src" "dist/frosty-${abi}-v${VERSION_NAME}.apk"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
ls -lh dist/
|
||||||
|
|
||||||
|
- name: Publish Forgejo release
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ steps.version.outputs.tag }}
|
||||||
|
VERSION_NAME: ${{ steps.version.outputs.version_name }}
|
||||||
|
VERSION_CODE: ${{ steps.version.outputs.version_code }}
|
||||||
|
SHORT_SHA: ${{ steps.version.outputs.short_sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
pkg_install() {
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then "$@"
|
||||||
|
elif command -v sudo >/dev/null 2>&1; then sudo "$@"
|
||||||
|
else return 1; fi
|
||||||
|
}
|
||||||
|
for pkg in curl jq; do
|
||||||
|
command -v "$pkg" >/dev/null 2>&1 && continue
|
||||||
|
if command -v apk >/dev/null 2>&1; then pkg_install apk add --no-cache "$pkg"
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then pkg_install dnf install -y "$pkg"
|
||||||
|
elif command -v apt-get >/dev/null 2>&1; then
|
||||||
|
pkg_install apt-get update
|
||||||
|
pkg_install apt-get install -y "$pkg"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
API="${GITHUB_API_URL:-${GITHUB_SERVER_URL}/api/v1}/repos/${GITHUB_REPOSITORY}"
|
||||||
|
AUTH="Authorization: token ${FORGEJO_TOKEN}"
|
||||||
|
|
||||||
|
BODY_FILE="$(mktemp)"
|
||||||
|
cat > "${BODY_FILE}" <<BODY_EOF
|
||||||
|
Frosty fork (Twitch client) — native player + TwitchAdSolutions adblock.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Version | ${VERSION_NAME} (${VERSION_CODE}) |
|
||||||
|
| Commit | ${SHORT_SHA} |
|
||||||
|
| Package | \`com.tommychow.frosty\` |
|
||||||
|
|
||||||
|
**Obtainium:** Forgejo source → \`…/copilot/frosty/releases\`, APK filter \`frosty-arm64-v8a-v.*\\.apk\`, trim \`v?([0-9]+\\.[0-9]+\\.[0-9]+)\` group \`1\`.
|
||||||
|
|
||||||
|
Stock Play Frosty uses a different signing key — uninstall it before installing this fork, or Obtainium cannot update in place.
|
||||||
|
BODY_EOF
|
||||||
|
|
||||||
|
# Create or update release for TAG
|
||||||
|
existing="$(curl -sS -H "$AUTH" "${API}/releases/tags/${TAG}" || true)"
|
||||||
|
release_id="$(printf '%s' "$existing" | jq -r '.id // empty')"
|
||||||
|
if [ -z "$release_id" ]; then
|
||||||
|
payload="$(jq -n \
|
||||||
|
--arg tag "$TAG" \
|
||||||
|
--arg name "Frosty ${VERSION_NAME} (${SHORT_SHA})" \
|
||||||
|
--rawfile body "$BODY_FILE" \
|
||||||
|
'{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:false}')"
|
||||||
|
resp="$(curl -sS -X POST -H "$AUTH" -H 'Content-Type: application/json' \
|
||||||
|
-d "$payload" "${API}/releases")"
|
||||||
|
release_id="$(printf '%s' "$resp" | jq -r '.id')"
|
||||||
|
else
|
||||||
|
payload="$(jq -n \
|
||||||
|
--arg name "Frosty ${VERSION_NAME} (${SHORT_SHA})" \
|
||||||
|
--rawfile body "$BODY_FILE" \
|
||||||
|
'{name:$name, body:$body, draft:false, prerelease:false}')"
|
||||||
|
curl -sS -X PATCH -H "$AUTH" -H 'Content-Type: application/json' \
|
||||||
|
-d "$payload" "${API}/releases/${release_id}" >/dev/null
|
||||||
|
fi
|
||||||
|
test -n "$release_id" && test "$release_id" != "null"
|
||||||
|
|
||||||
|
for apk in dist/*.apk; do
|
||||||
|
[ -f "$apk" ] || continue
|
||||||
|
name="$(basename "$apk")"
|
||||||
|
echo "Uploading ${name}…"
|
||||||
|
curl -sS -X POST -H "$AUTH" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"${apk}" \
|
||||||
|
"${API}/releases/${release_id}/assets?name=${name}" \
|
||||||
|
| jq -r '.name // .message'
|
||||||
|
done
|
||||||
|
echo "Published ${TAG} as release ${release_id}"
|
||||||
|
rm -f "$BODY_FILE"
|
||||||
33
.github/dependabot.yml
vendored
Normal file
33
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
# Keep GitHub Actions up to date
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
commit-message:
|
||||||
|
prefix: "ci"
|
||||||
|
groups:
|
||||||
|
actions:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
|
||||||
|
# Keep Ruby gems (fastlane) up to date
|
||||||
|
- package-ecosystem: "bundler"
|
||||||
|
directory: "/android"
|
||||||
|
schedule:
|
||||||
|
interval: "monthly"
|
||||||
|
|
||||||
|
- package-ecosystem: "bundler"
|
||||||
|
directory: "/ios"
|
||||||
|
schedule:
|
||||||
|
interval: "monthly"
|
||||||
|
|
||||||
|
# Keep Dart/Flutter packages up to date
|
||||||
|
- package-ecosystem: "pub"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
ignore:
|
||||||
|
- dependency-name: "*"
|
||||||
|
update-types: ["version-update:semver-major"]
|
||||||
53
.github/workflows/ci.yml
vendored
53
.github/workflows/ci.yml
vendored
|
|
@ -3,18 +3,57 @@ on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
# Linux runner: analyze + test once (both are OS-independent) and compile a
|
||||||
name: Check and test on ${{ matrix.os }}
|
# release APK to catch release-only breakage. Debug-signed via the build.gradle
|
||||||
runs-on: ${{ matrix.os }}
|
# fallback (ALLOW_DEBUG_SIGNED_RELEASE) — no signing secrets needed.
|
||||||
strategy:
|
android:
|
||||||
matrix:
|
name: Analyze, test, and build Android
|
||||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
# Match the JDK the release workflows build with, so this compile check
|
||||||
|
# faithfully catches what would break a real release.
|
||||||
|
- uses: actions/setup-java@v5
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 21
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
|
flutter-version-file: pubspec.yaml
|
||||||
cache: true
|
cache: true
|
||||||
- run: flutter doctor -v
|
- run: flutter doctor -v
|
||||||
- run: flutter pub get
|
- run: flutter pub get
|
||||||
|
# Fail if committed generated files (*.g.dart) are stale — CI never runs
|
||||||
|
# build_runner otherwise, so forgetting to regenerate after editing a MobX
|
||||||
|
# store or @JsonSerializable model slips past analyze/test/build.
|
||||||
|
- run: dart run build_runner build --delete-conflicting-outputs
|
||||||
|
- name: Verify generated files are up to date
|
||||||
|
run: |
|
||||||
|
if [ -n "$(git status --porcelain)" ]; then
|
||||||
|
echo '::error::Generated files are out of date. Run `dart run build_runner build --delete-conflicting-outputs` and commit the result.'
|
||||||
|
git status --porcelain
|
||||||
|
git diff
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
- run: flutter analyze
|
- run: flutter analyze
|
||||||
- run: flutter test
|
- run: flutter test
|
||||||
|
- run: flutter build apk --release
|
||||||
|
env:
|
||||||
|
ALLOW_DEBUG_SIGNED_RELEASE: '1'
|
||||||
|
# macOS runner: release-compile iOS (no codesign, no secrets) — the only thing
|
||||||
|
# that must run on macOS. analyze/test already covered on the Linux job.
|
||||||
|
ios:
|
||||||
|
name: Build iOS
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
# Match release workflows and avoid GitHub's lagging default Xcode.
|
||||||
|
- uses: maxim-lobanov/setup-xcode@v1
|
||||||
|
with:
|
||||||
|
xcode-version: latest-stable
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version-file: pubspec.yaml
|
||||||
|
cache: true
|
||||||
|
- run: flutter pub get
|
||||||
|
- run: flutter build ios --release --no-codesign
|
||||||
|
|
|
||||||
54
.github/workflows/gh-release.yml
vendored
54
.github/workflows/gh-release.yml
vendored
|
|
@ -19,11 +19,11 @@ jobs:
|
||||||
SECRET: ${{ secrets.SECRET }}
|
SECRET: ${{ secrets.SECRET }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Frosty repo
|
- name: Checkout Frosty repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
# Setup Java so that we can build the APK.
|
# Setup Java so that we can build the APK.
|
||||||
- name: Set up Java
|
- name: Set up Java
|
||||||
uses: actions/setup-java@v3
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 21
|
java-version: 21
|
||||||
|
|
@ -32,6 +32,7 @@ jobs:
|
||||||
- name: Set up Flutter
|
- name: Set up Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
|
flutter-version-file: pubspec.yaml
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
# Decode and create the upload-keystore.jks file, used for signing the app.
|
# Decode and create the upload-keystore.jks file, used for signing the app.
|
||||||
|
|
@ -51,11 +52,54 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
flutter build apk --dart-define CLIENT_ID=$CLIENT_ID --dart-define SECRET=$SECRET
|
flutter build apk --dart-define CLIENT_ID=$CLIENT_ID --dart-define SECRET=$SECRET
|
||||||
mv build/app/outputs/flutter-apk/app-release.apk frosty-${{ github.ref_name }}.apk
|
mv build/app/outputs/flutter-apk/app-release.apk frosty-${{ github.ref_name }}.apk
|
||||||
|
|
||||||
|
# Extract release notes from assets/release-notes.md for the current version.
|
||||||
|
- name: Extract release notes
|
||||||
|
id: release_notes
|
||||||
|
run: |
|
||||||
|
VERSION="${{ github.ref_name }}"
|
||||||
|
RELEASE_NOTES_FILE="assets/release-notes.md"
|
||||||
|
|
||||||
|
# Extract the section for this version using awk
|
||||||
|
# Starts at "## vX.X.X", skips date line, stops at next "---" or "## v" header
|
||||||
|
NOTES=$(awk -v version="$VERSION" '
|
||||||
|
BEGIN { found=0; output="" }
|
||||||
|
$0 ~ "^## " version "$" { found=1; next }
|
||||||
|
found && /^[A-Z][a-z]+ [0-9]+, [0-9]+$/ { next }
|
||||||
|
found && /^---$/ { exit }
|
||||||
|
found && /^## v[0-9]+\.[0-9]+\.[0-9]+$/ { exit }
|
||||||
|
found { output = output $0 "\n" }
|
||||||
|
END { print output }
|
||||||
|
' "$RELEASE_NOTES_FILE")
|
||||||
|
|
||||||
|
# Clean up "on GitHub" suffix (redundant since we're on GitHub)
|
||||||
|
NOTES=$(echo "$NOTES" | sed 's/ on GitHub)/)/')
|
||||||
|
|
||||||
|
# Trim leading/trailing whitespace
|
||||||
|
NOTES=$(echo "$NOTES" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
||||||
|
|
||||||
|
if [ -n "$NOTES" ]; then
|
||||||
|
# Get the previous tag for the Full Changelog link
|
||||||
|
PREV_TAG=$(git describe --tags --abbrev=0 "$VERSION^" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
echo "found=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "body<<EOF" >> $GITHUB_OUTPUT
|
||||||
|
echo "$NOTES" >> $GITHUB_OUTPUT
|
||||||
|
if [ -n "$PREV_TAG" ]; then
|
||||||
|
echo "" >> $GITHUB_OUTPUT
|
||||||
|
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREV_TAG}...${VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "found=false" >> $GITHUB_OUTPUT
|
||||||
|
echo "::warning::No release notes found for $VERSION in $RELEASE_NOTES_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
# Create a draft release on GitHub.
|
# Create a draft release on GitHub.
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
draft: true
|
draft: true
|
||||||
generate_release_notes: true
|
generate_release_notes: ${{ steps.release_notes.outputs.found != 'true' }}
|
||||||
|
body: ${{ steps.release_notes.outputs.found == 'true' && steps.release_notes.outputs.body || '' }}
|
||||||
files: frosty-${{ github.ref_name }}.apk
|
files: frosty-${{ github.ref_name }}.apk
|
||||||
|
|
|
||||||
28
.github/workflows/pr-check.yml
vendored
28
.github/workflows/pr-check.yml
vendored
|
|
@ -7,11 +7,37 @@ jobs:
|
||||||
name: Check and test
|
name: Check and test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
# Match the JDK the release workflows build with, so the release-mode APK
|
||||||
|
# check below faithfully catches what would break a real release.
|
||||||
|
- uses: actions/setup-java@v5
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 21
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
|
flutter-version-file: pubspec.yaml
|
||||||
cache: true
|
cache: true
|
||||||
- run: flutter doctor -v
|
- run: flutter doctor -v
|
||||||
- run: flutter pub get
|
- run: flutter pub get
|
||||||
|
# Fail if committed generated files (*.g.dart) are stale — CI never runs
|
||||||
|
# build_runner otherwise, so forgetting to regenerate after editing a MobX
|
||||||
|
# store or @JsonSerializable model slips past analyze/test/build.
|
||||||
|
- run: dart run build_runner build --delete-conflicting-outputs
|
||||||
|
- name: Verify generated files are up to date
|
||||||
|
run: |
|
||||||
|
if [ -n "$(git status --porcelain)" ]; then
|
||||||
|
echo '::error::Generated files are out of date. Run `dart run build_runner build --delete-conflicting-outputs` and commit the result.'
|
||||||
|
git status --porcelain
|
||||||
|
git diff
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
- run: flutter analyze
|
- run: flutter analyze
|
||||||
- run: flutter test
|
- run: flutter test
|
||||||
|
# Release-mode compile check (no signing secrets needed — Android falls back
|
||||||
|
# to debug signing via ALLOW_DEBUG_SIGNED_RELEASE). Catches release-only
|
||||||
|
# breakage that debug builds and tests miss (AOT compile, icon tree-shaking,
|
||||||
|
# plugin/Gradle release incompatibility).
|
||||||
|
- run: flutter build apk --release
|
||||||
|
env:
|
||||||
|
ALLOW_DEBUG_SIGNED_RELEASE: '1'
|
||||||
|
|
|
||||||
126
.github/workflows/release-dryrun.yml
vendored
Normal file
126
.github/workflows/release-dryrun.yml
vendored
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
# Dry-runs the full store release pipeline without publishing, to catch
|
||||||
|
# credential, signing, dependency, and store-validation breakage before an
|
||||||
|
# actual release. Mirrors store-release.yml but stops short of publishing:
|
||||||
|
# Android validates the AAB against Google Play (validate_only); iOS builds a
|
||||||
|
# fully signed archive via match (readonly) and skips the App Store upload.
|
||||||
|
name: Release dry-run
|
||||||
|
on:
|
||||||
|
# Run on demand before cutting a release.
|
||||||
|
workflow_dispatch:
|
||||||
|
# Monthly heartbeat so cert expiry, secret rotation, and dependency drift
|
||||||
|
# surface on their own between releases.
|
||||||
|
schedule:
|
||||||
|
- cron: '0 9 1 * *' # 1st of the month, 09:00 UTC
|
||||||
|
# Setup environment variables for use in all jobs.
|
||||||
|
env:
|
||||||
|
# Gemfile path used for caching dependencies.
|
||||||
|
BUNDLE_GEMFILE: ${{ github.workspace }}/android/Gemfile
|
||||||
|
CLIENT_ID: ${{ secrets.CLIENT_ID }}
|
||||||
|
SECRET: ${{ secrets.SECRET }}
|
||||||
|
LC_ALL: en_US.UTF-8
|
||||||
|
LANG: en_US.UTF-8
|
||||||
|
jobs:
|
||||||
|
android:
|
||||||
|
name: Dry-run Android release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: release-android
|
||||||
|
env:
|
||||||
|
UPLOAD_KEYSTORE: ${{ secrets.UPLOAD_KEYSTORE }}
|
||||||
|
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
||||||
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
KEY_FILE_PATH: android/key.properties
|
||||||
|
PLAY_STORE_CREDENTIALS: ${{ secrets.PLAY_STORE_CREDENTIALS }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Frosty repo
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
# Install Ruby and install/cache dependencies with bundler in order to use fastlane.
|
||||||
|
- name: Set up Ruby and Bundler
|
||||||
|
uses: ruby/setup-ruby@v1
|
||||||
|
with:
|
||||||
|
ruby-version: 3.3
|
||||||
|
bundler-cache: true
|
||||||
|
|
||||||
|
# Setup Java so that we can build the appbundle.
|
||||||
|
- name: Set up Java
|
||||||
|
uses: actions/setup-java@v5
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 21
|
||||||
|
|
||||||
|
# Setup and cache Flutter to install packages and build.
|
||||||
|
- name: Set up Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version-file: pubspec.yaml
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
# Decode and create the upload-keystore.jks file, used for signing the app.
|
||||||
|
- name: Decode and create upload-keystore.jks
|
||||||
|
run: echo $UPLOAD_KEYSTORE | base64 --decode > $GITHUB_WORKSPACE/android/upload-keystore.jks
|
||||||
|
|
||||||
|
# Create the key.properties file, used for signing the app.
|
||||||
|
- name: Create key.properties
|
||||||
|
run: |
|
||||||
|
echo storePassword=$STORE_PASSWORD >> $KEY_FILE_PATH
|
||||||
|
echo keyPassword=$KEY_PASSWORD >> $KEY_FILE_PATH
|
||||||
|
echo keyAlias=upload >> $KEY_FILE_PATH
|
||||||
|
echo storeFile=$GITHUB_WORKSPACE/android/upload-keystore.jks >> $KEY_FILE_PATH
|
||||||
|
|
||||||
|
# Build the Android app bundle (AAB) and pass the environment variables.
|
||||||
|
#
|
||||||
|
# The version code is overridden because Play rejects one it has already
|
||||||
|
# seen, and pubspec's code is by definition already published between
|
||||||
|
# releases — so the dry-run would fail on every scheduled run and never
|
||||||
|
# reach the validation it exists to perform. Epoch seconds is always
|
||||||
|
# unused, always increasing, and well under Play's 2100000000 cap. The
|
||||||
|
# bundle is only ever validated, never published, so the value is thrown
|
||||||
|
# away. versionName still comes from pubspec.
|
||||||
|
- name: Build the Android AAB
|
||||||
|
run: flutter build appbundle --build-number=$(date +%s) --dart-define CLIENT_ID=$CLIENT_ID --dart-define SECRET=$SECRET
|
||||||
|
|
||||||
|
# Validate the AAB and credentials against Google Play without publishing.
|
||||||
|
- name: Validate release with fastlane (no publish)
|
||||||
|
run: cd android && bundle exec fastlane deploy dry_run:true
|
||||||
|
ios:
|
||||||
|
name: Dry-run iOS release
|
||||||
|
runs-on: macos-latest
|
||||||
|
environment: release-ios
|
||||||
|
env:
|
||||||
|
APP_STORE_KEY_ID: ${{ secrets.APP_STORE_KEY_ID }}
|
||||||
|
APP_STORE_ISSUER_ID: ${{ secrets.APP_STORE_ISSUER_ID }}
|
||||||
|
APP_STORE_CREDENTIALS: ${{ secrets.APP_STORE_CREDENTIALS }}
|
||||||
|
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
|
||||||
|
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout Frosty repo
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
# Always pick the newest production Xcode on the runner.
|
||||||
|
- uses: maxim-lobanov/setup-xcode@v1
|
||||||
|
with:
|
||||||
|
xcode-version: latest-stable
|
||||||
|
|
||||||
|
# Install Ruby and install/cache dependencies with bundler in order to use fastlane.
|
||||||
|
- name: Set up Ruby and Bundler
|
||||||
|
uses: ruby/setup-ruby@v1
|
||||||
|
with:
|
||||||
|
ruby-version: 3.3
|
||||||
|
bundler-cache: true
|
||||||
|
|
||||||
|
# Setup and cache Flutter to install packages and build.
|
||||||
|
- name: Set up Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version-file: pubspec.yaml
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
# Setup the iOS project with environment variables without signing (building and signing will happen in fastlane).
|
||||||
|
- name: Config the iOS build
|
||||||
|
run: flutter build ios --no-codesign --config-only --dart-define CLIENT_ID=$CLIENT_ID --dart-define SECRET=$SECRET
|
||||||
|
|
||||||
|
# Authenticate ASC API key, resolve signing via match (readonly), and build a
|
||||||
|
# fully signed archive without uploading to the App Store.
|
||||||
|
- name: Sign and build with fastlane (no publish)
|
||||||
|
run: cd ios && bundle exec fastlane release dry_run:true
|
||||||
33
.github/workflows/store-release.yml
vendored
33
.github/workflows/store-release.yml
vendored
|
|
@ -29,32 +29,33 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Frosty repo
|
- name: Checkout Frosty repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
# Install Ruby and install/cache dependencies with bundler in order to use fastlane.
|
# Install Ruby and install/cache dependencies with bundler in order to use fastlane.
|
||||||
- name: Set up Ruby and Bundler
|
- name: Set up Ruby and Bundler
|
||||||
uses: ruby/setup-ruby@v1
|
uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
ruby-version: 3.2
|
ruby-version: 3.3
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
|
|
||||||
# Setup Java so that we can build the appbundle.
|
# Setup Java so that we can build the appbundle.
|
||||||
- name: Set up Java
|
- name: Set up Java
|
||||||
uses: actions/setup-java@v3
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 21
|
java-version: 21
|
||||||
|
|
||||||
# Setup and cache Flutter to install packages and build.
|
# Setup and cache Flutter to install packages and build.
|
||||||
- name: Set up Flutter
|
- name: Set up Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
|
flutter-version-file: pubspec.yaml
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
# Decode and create the upload-keystore.jks file, used for signing the app.
|
# Decode and create the upload-keystore.jks file, used for signing the app.
|
||||||
- name: Decode and create upload-keystore.jks
|
- name: Decode and create upload-keystore.jks
|
||||||
run: echo $UPLOAD_KEYSTORE | base64 --decode > $GITHUB_WORKSPACE/android/upload-keystore.jks
|
run: echo $UPLOAD_KEYSTORE | base64 --decode > $GITHUB_WORKSPACE/android/upload-keystore.jks
|
||||||
|
|
||||||
# Create the key.properties file, used for signing the app.
|
# Create the key.properties file, used for signing the app.
|
||||||
- name: Create key.properties
|
- name: Create key.properties
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -62,11 +63,11 @@ jobs:
|
||||||
echo keyPassword=$KEY_PASSWORD >> $KEY_FILE_PATH
|
echo keyPassword=$KEY_PASSWORD >> $KEY_FILE_PATH
|
||||||
echo keyAlias=upload >> $KEY_FILE_PATH
|
echo keyAlias=upload >> $KEY_FILE_PATH
|
||||||
echo storeFile=$GITHUB_WORKSPACE/android/upload-keystore.jks >> $KEY_FILE_PATH
|
echo storeFile=$GITHUB_WORKSPACE/android/upload-keystore.jks >> $KEY_FILE_PATH
|
||||||
|
|
||||||
# Build the Android app bundle (AAB) and pass the environment variables.
|
# Build the Android app bundle (AAB) and pass the environment variables.
|
||||||
- name: Build the Android AAB
|
- name: Build the Android AAB
|
||||||
run: flutter build appbundle --dart-define CLIENT_ID=$CLIENT_ID --dart-define SECRET=$SECRET
|
run: flutter build appbundle --dart-define CLIENT_ID=$CLIENT_ID --dart-define SECRET=$SECRET
|
||||||
|
|
||||||
# Deploy to the play store with fastlane.
|
# Deploy to the play store with fastlane.
|
||||||
- name: Deploy with fastlane
|
- name: Deploy with fastlane
|
||||||
run: cd android && bundle exec fastlane deploy
|
run: cd android && bundle exec fastlane deploy
|
||||||
|
|
@ -82,19 +83,25 @@ jobs:
|
||||||
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
|
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Frosty repo
|
- name: Checkout Frosty repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
# Always pick the newest production Xcode on the runner.
|
||||||
|
- uses: maxim-lobanov/setup-xcode@v1
|
||||||
|
with:
|
||||||
|
xcode-version: latest-stable
|
||||||
|
|
||||||
# Install Ruby and install/cache dependencies with bundler in order to use fastlane.
|
# Install Ruby and install/cache dependencies with bundler in order to use fastlane.
|
||||||
- name: Set up Ruby and Bundler
|
- name: Set up Ruby and Bundler
|
||||||
uses: ruby/setup-ruby@v1
|
uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
ruby-version: 3.2
|
ruby-version: 3.3
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
|
|
||||||
# Setup and cache Flutter to install packages and build.
|
# Setup and cache Flutter to install packages and build.
|
||||||
- name: Set up Flutter
|
- name: Set up Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
|
flutter-version-file: pubspec.yaml
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
# Setup the iOS project with environment variables without signing (building and signing will happen in fastlane).
|
# Setup the iOS project with environment variables without signing (building and signing will happen in fastlane).
|
||||||
|
|
|
||||||
13
.gitignore
vendored
13
.gitignore
vendored
|
|
@ -21,7 +21,7 @@ migrate_working_dir/
|
||||||
# The .vscode folder contains launch configuration and tasks you configure in
|
# The .vscode folder contains launch configuration and tasks you configure in
|
||||||
# VS Code which you may wish to be included in version control, so this line
|
# VS Code which you may wish to be included in version control, so this line
|
||||||
# is commented out by default.
|
# is commented out by default.
|
||||||
.vscode/
|
# .vscode/
|
||||||
|
|
||||||
# Flutter/Dart/Pub related
|
# Flutter/Dart/Pub related
|
||||||
**/doc/api/
|
**/doc/api/
|
||||||
|
|
@ -32,6 +32,7 @@ migrate_working_dir/
|
||||||
.pub-cache/
|
.pub-cache/
|
||||||
.pub/
|
.pub/
|
||||||
/build/
|
/build/
|
||||||
|
/coverage/
|
||||||
|
|
||||||
# Symbolication related
|
# Symbolication related
|
||||||
app.*.symbols
|
app.*.symbols
|
||||||
|
|
@ -43,6 +44,8 @@ app.*.map.json
|
||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
# AGP 9 writes a problems report here even when the Gradle build dir is redirected
|
||||||
|
/android/build/
|
||||||
|
|
||||||
# Folder containing random testing files
|
# Folder containing random testing files
|
||||||
random/
|
random/
|
||||||
|
|
@ -61,4 +64,10 @@ random/
|
||||||
**/fastlane/test_output
|
**/fastlane/test_output
|
||||||
|
|
||||||
# potentially sensitive review information
|
# potentially sensitive review information
|
||||||
**/fastlane/metadata/review_information
|
**/fastlane/metadata/review_information
|
||||||
|
|
||||||
|
# secrets
|
||||||
|
.env
|
||||||
|
|
||||||
|
# ai
|
||||||
|
.claude/settings.local.json
|
||||||
12
.mcp.json
Normal file
12
.mcp.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"dart": {
|
||||||
|
"command": "dart",
|
||||||
|
"args": ["mcp-server", "--force-roots-fallback"]
|
||||||
|
},
|
||||||
|
"next-devtools": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["web/node_modules/next-devtools-mcp/dist/index.js"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
.vscode/launch.json
vendored
Normal file
25
.vscode/launch.json
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "frosty",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "dart",
|
||||||
|
"args": ["--dart-define-from-file=.env"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "frosty (profile mode)",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "dart",
|
||||||
|
"flutterMode": "profile",
|
||||||
|
"args": ["--dart-define-from-file=.env"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "frosty (release mode)",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "dart",
|
||||||
|
"flutterMode": "release",
|
||||||
|
"args": ["--dart-define-from-file=.env"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
46
AGENTS.md
Normal file
46
AGENTS.md
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Flutter Twitch client for iOS and Android. MobX with `build_runner`. The landing page is `web/` (own `AGENTS.md`). Flutter is pinned in `pubspec.yaml` `environment.flutter` — bump that one field; CI reads it via `flutter-version-file`.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter pub get
|
||||||
|
flutter run --dart-define-from-file=.env # local; .env is gitignored
|
||||||
|
flutter run --dart-define=CLIENT_ID=ID --dart-define=SECRET=SECRET
|
||||||
|
flutter analyze
|
||||||
|
flutter test
|
||||||
|
flutter test test/path/to/file.dart
|
||||||
|
dart run build_runner build # after MobX / @JsonSerializable changes
|
||||||
|
```
|
||||||
|
|
||||||
|
`--delete-conflicting-outputs` is ignored as of build_runner 2.15. Don't add it.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `lib/apis/` — API clients extend `BaseApiClient`
|
||||||
|
- `lib/models/` — data models; `@JsonSerializable` ones commit `.g.dart`
|
||||||
|
- `lib/screens/{feature}/` — screens; feature stores in co-located `stores/`
|
||||||
|
- `lib/stores/` — global stores only (`global_assets_store.dart`)
|
||||||
|
- `test/` mirrors `lib/`; shared fixtures in `test/fixtures/`
|
||||||
|
|
||||||
|
Don't add top-level store files or standalone API classes. Canonical MobX store: `lib/screens/settings/stores/auth_store.dart`. Extract state, reactions, and actions into stores when it keeps widgets on rendering.
|
||||||
|
|
||||||
|
New or changed logic (stores, API clients, parsing, utils) ships with tests; extend existing tests when touching tested code. Pure UI or config may skip. HTTP: `http_mock_adapter` (`DioAdapter`) with full URLs. Otherwise `mocktail`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Commit regenerated `.g.dart` files. Never edit them. CI fails the PR if they're stale.
|
||||||
|
- `main.dart` secure-storage `deleteAll()` looks redundant; it handles Android/iOS storage surviving uninstall. Don't remove it.
|
||||||
|
- `flutter_secure_storage` stays on v9 (v10 shipped in 5.0.0 and was reverted in 5.0.1 over an Android 14 startup freeze). That also holds `package_info_plus` v9 and `device_info_plus` v12 (win32). Don't jump them in a drive-by upgrade.
|
||||||
|
- Git-pinned forks in `pubspec.yaml` (`extended_text_field`, `better_native_video_player`, `simple_pip_mode`) carry patches. Don't swap them back to pub.dev.
|
||||||
|
- `android/gradle.properties`: keep `android.builtInKotlin=false` and `android.newDsl=false` so plugins that still apply KGP build on AGP 9.
|
||||||
|
- `ios/Runner/AppDelegate.swift` is customized (`CookieExtractorPlugin` + `FlutterImplicitEngineDelegate`). Flutter will not auto-migrate UIScene; don't replace the delegate with the template.
|
||||||
|
- `android/.../MainActivity.kt` extends `PipCallbackHelperActivityWrapper` and registers `CookieExtractorPlugin` by hand. Don't revert it to `FlutterActivity`.
|
||||||
|
- Helix OAuth (`Bearer`) and the Twitch web GQL cookie are different tokens. Native playback uses the cookie; don't send the Helix token to GQL/Usher.
|
||||||
|
- `syncedChatDelay` is runtime-only (`includeFromJson/toJson: false`). Don't persist it with the other settings.
|
||||||
|
- Don't remove the `ALLOW_DEBUG_SIGNED_RELEASE` branch in `android/app/build.gradle.kts`. Real releases must still fail if `key.properties` is missing.
|
||||||
|
|
||||||
|
## Releases
|
||||||
|
|
||||||
|
When cutting a store or GitHub release, read `docs/release.md` and follow it. That playbook is repo-local, not a global skill. `assets/release-notes.md` keeps about three consecutive versions; drop from the oldest end, never skip.
|
||||||
61
DESIGN.md
Normal file
61
DESIGN.md
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
# Frosty fork — Twitch ad blocking (DESIGN)
|
||||||
|
|
||||||
|
Personal Forgejo fork of [tommyxchow/frosty](https://github.com/tommyxchow/frosty) that adds **TwitchAdSolutions (vaft)**-style live ad blocking.
|
||||||
|
|
||||||
|
Canonical living plan: session `active-plan.md` for conversation `37b527bf-e540-4753-be1f-39906fe2ba28`. ADR: **ADR-026**.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Upstream Frosty plays Twitch HLS (native) or `player.twitch.tv` (WebView). Twitch may stitch ad pods into the media playlist (`stitched` / `EXT-X-DATERANGE`). Upstream only **detects** native ad breaks (to avoid bogus latency recovery); it does **not** block ads. Turbo/sub `hide_ads` via linked web session is separate and not a client blocker.
|
||||||
|
|
||||||
|
## Approach (vaft-equivalent)
|
||||||
|
|
||||||
|
Browser vaft hooks `Worker`/`fetch` and rewrites m3u8. Frosty’s native player cannot do that from Dart (`loadUrl` + headers only).
|
||||||
|
|
||||||
|
| Layer | Behavior |
|
||||||
|
| --- | --- |
|
||||||
|
| Primary token | GQL `PlaybackAccessToken` with `playerType=popout` (vaft `ForceAccessTokenPlayerType`) when Settings `blockTwitchAds` is on |
|
||||||
|
| Ad break | Native `adBreakStream` → probe backup types `embed` → `popout` → `autoplay`; accept first Usher multivariant whose sampled media playlist lacks `stitched`; `loadUrl` that URL |
|
||||||
|
| Ad end | Restore primary popout Usher URL |
|
||||||
|
| WebView | Best-effort inject of vendored `assets/js/vaft.user.js` (not true document-start on Android) |
|
||||||
|
| UI | Settings toggle (default on; enabling forces native player); overlay “Blocking ads” |
|
||||||
|
|
||||||
|
## Key code
|
||||||
|
|
||||||
|
- `lib/apis/twitch_ad_solutions.dart` — constants + `TwitchAdFreePlayback`
|
||||||
|
- `lib/apis/twitch_gql_api.dart` — `playerType` / `platform`, `fetchPlaylistText`
|
||||||
|
- `lib/screens/channel/video/native_video_store.dart` — load + swap
|
||||||
|
- `lib/screens/channel/video/video_store.dart` — WebView inject
|
||||||
|
- `lib/screens/settings/stores/settings_store.dart` — `blockTwitchAds`
|
||||||
|
- `assets/js/vaft.user.js` — upstream TwitchAdSolutions script
|
||||||
|
|
||||||
|
## Non-goals (v1)
|
||||||
|
|
||||||
|
- Upstream PR
|
||||||
|
- Full Worker prototype cleaning
|
||||||
|
- Third-party proxies
|
||||||
|
- HLS segment stripping inside `better_native_video_player` (unless smoke tests demand it)
|
||||||
|
|
||||||
|
## Tags + viewer sort (v5.2.2+)
|
||||||
|
|
||||||
|
Helix cannot filter by freeform tags or sort ascending. GQL covers both:
|
||||||
|
|
||||||
|
- Search: `SearchFreeformTags` → tap → `TagStreams` (`streams` + `freeformTags`)
|
||||||
|
- Sort: Top (asc), category (`game.streams`), tag — `VIEWER_COUNT` / `VIEWER_COUNT_ASC` via `StreamSortButton`
|
||||||
|
|
||||||
|
Key code: `lib/apis/twitch_gql_api.dart`, `lib/screens/home/search/`, `lib/widgets/stream_sort_button.dart`, `ListStore` / `ListType.tag`.
|
||||||
|
|
||||||
|
## Stream languages (v5.2.4+)
|
||||||
|
|
||||||
|
Settings → General → Stream languages (multi-select chips). Default `de`. Applied to Top, category, and tag lists.
|
||||||
|
|
||||||
|
- Helix `language=` when one language; multi-language / tags via Helix over-fetch + client filter
|
||||||
|
- Viewer sort is persisted (`streamViewerSort`)
|
||||||
|
- **Tag lists (asc + desc):** GQL `streams` with `freeformTags` + `sort` + cursor. Page-2+ uses Client-Integrity (same mint/retry as follow via `UserStore.runWithGqlIntegrity`). Helix + client tag filter only if integrity fails / no web session.
|
||||||
|
- **Follow / integrity:** Dio `POST /integrity` is almost always `is_bad_bot`. Follow retries via `IntegrityWebViewDialog` (capture `Client-Integrity` + `X-Device-Id` from Twitch's own WebView GQL traffic), then `UserStore.applyWebIntegrity`.
|
||||||
|
- **Top / category most viewers (desc):** Helix directory order (paginates)
|
||||||
|
- **Top / category fewest (asc):** GQL `VIEWER_COUNT_ASC` with the same integrity pagination; Helix fill + client re-sort as fallback
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Flutter **3.47.0** (pinned in `pubspec.yaml`). Local SDK: `/home/yuna/.local/flutter-3.47.0`.
|
||||||
10
README.md
10
README.md
|
|
@ -85,11 +85,17 @@ For a more detailed overview, visit [frostyapp.io](https://www.frostyapp.io/).
|
||||||
|
|
||||||
3. Go to the [Twitch dev console](https://dev.twitch.tv/login) and register a new application to retrieve a **Client ID** and **Client Secret**.
|
3. Go to the [Twitch dev console](https://dev.twitch.tv/login) and register a new application to retrieve a **Client ID** and **Client Secret**.
|
||||||
|
|
||||||
4. Use [`--dart-define`](https://dartcode.org/docs/using-dart-define-in-flutter/) to set the `clientId` and `secret` environment variables with your **Client ID** and **Client Secret**.
|
4. Copy `.env.example` to `.env` and fill in your credentials:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
5. Run `flutter pub get` to fetch all the dependencies.
|
5. Run `flutter pub get` to fetch all the dependencies.
|
||||||
|
|
||||||
6. Choose an emulator or device and run the app!
|
6. Choose an emulator or device and run the app! The included VS Code launch configurations will automatically read your `.env` file.
|
||||||
|
|
||||||
|
7. (Optional) Run `dart run scripts/get_twitch_token.dart` to quickly get a user auth token. Long-press the **Anonymous** account tile in the app to log in.
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> Frosty uses [MobX](https://mobx.netlify.app/) for state management. Please refer to the documentation about code generation, otherwise your changes within MobX stores may not be applied.
|
> Frosty uses [MobX](https://mobx.netlify.app/) for state management. Please refer to the documentation about code generation, otherwise your changes within MobX stores may not be applied.
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,14 @@ include: package:flutter_lints/flutter.yaml
|
||||||
analyzer:
|
analyzer:
|
||||||
exclude:
|
exclude:
|
||||||
- lib/**.g.dart
|
- lib/**.g.dart
|
||||||
|
- .dart_tool/**
|
||||||
|
- build/**
|
||||||
|
- ios/**
|
||||||
|
- android/**
|
||||||
|
- web/**
|
||||||
|
- windows/**
|
||||||
|
- macos/**
|
||||||
|
- linux/**
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
# The lint rules applied to this project can be customized in the
|
# The lint rules applied to this project can be customized in the
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,49 @@
|
||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
CFPropertyList (3.0.7)
|
CFPropertyList (3.0.8)
|
||||||
base64
|
abbrev (0.1.2)
|
||||||
nkf
|
addressable (2.9.0)
|
||||||
rexml
|
public_suffix (>= 2.0.2, < 8.0)
|
||||||
addressable (2.8.7)
|
|
||||||
public_suffix (>= 2.0.2, < 7.0)
|
|
||||||
artifactory (3.0.17)
|
artifactory (3.0.17)
|
||||||
atomos (0.1.3)
|
atomos (0.1.3)
|
||||||
aws-eventstream (1.3.2)
|
aws-eventstream (1.4.0)
|
||||||
aws-partitions (1.1082.0)
|
aws-partitions (1.1260.0)
|
||||||
aws-sdk-core (3.222.1)
|
aws-sdk-core (3.252.0)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
aws-sigv4 (~> 1.9)
|
aws-sigv4 (~> 1.9)
|
||||||
base64
|
base64
|
||||||
|
bigdecimal
|
||||||
jmespath (~> 1, >= 1.6.1)
|
jmespath (~> 1, >= 1.6.1)
|
||||||
logger
|
logger
|
||||||
aws-sdk-kms (1.99.0)
|
aws-sdk-kms (1.129.0)
|
||||||
aws-sdk-core (~> 3, >= 3.216.0)
|
aws-sdk-core (~> 3, >= 3.248.0)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-s3 (1.183.0)
|
aws-sdk-s3 (1.225.1)
|
||||||
aws-sdk-core (~> 3, >= 3.216.0)
|
aws-sdk-core (~> 3, >= 3.248.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sigv4 (1.11.0)
|
aws-sigv4 (1.12.1)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
babosa (1.0.4)
|
babosa (1.0.4)
|
||||||
base64 (0.2.0)
|
base64 (0.3.0)
|
||||||
|
benchmark (0.5.0)
|
||||||
|
bigdecimal (4.1.2)
|
||||||
claide (1.1.0)
|
claide (1.1.0)
|
||||||
colored (1.2)
|
colored (1.2)
|
||||||
colored2 (3.1.2)
|
colored2 (3.1.2)
|
||||||
commander (4.6.0)
|
commander (4.6.0)
|
||||||
highline (~> 2.0.0)
|
highline (~> 2.0.0)
|
||||||
|
csv (3.3.5)
|
||||||
declarative (0.0.20)
|
declarative (0.0.20)
|
||||||
digest-crc (0.7.0)
|
digest-crc (0.7.0)
|
||||||
rake (>= 12.0.0, < 14.0.0)
|
rake (>= 12.0.0, < 14.0.0)
|
||||||
domain_name (0.5.20190701)
|
domain_name (0.6.20240107)
|
||||||
unf (>= 0.0.5, < 1.0.0)
|
|
||||||
dotenv (2.8.1)
|
dotenv (2.8.1)
|
||||||
emoji_regex (3.2.3)
|
emoji_regex (3.2.3)
|
||||||
excon (0.109.0)
|
excon (0.112.0)
|
||||||
faraday (1.10.4)
|
faraday (1.10.5)
|
||||||
faraday-em_http (~> 1.0)
|
faraday-em_http (~> 1.0)
|
||||||
faraday-em_synchrony (~> 1.0)
|
faraday-em_synchrony (~> 1.0)
|
||||||
faraday-excon (~> 1.1)
|
faraday-excon (~> 1.1)
|
||||||
|
|
@ -54,32 +55,36 @@ GEM
|
||||||
faraday-rack (~> 1.0)
|
faraday-rack (~> 1.0)
|
||||||
faraday-retry (~> 1.0)
|
faraday-retry (~> 1.0)
|
||||||
ruby2_keywords (>= 0.0.4)
|
ruby2_keywords (>= 0.0.4)
|
||||||
faraday-cookie_jar (0.0.7)
|
faraday-cookie_jar (0.0.8)
|
||||||
faraday (>= 0.8.0)
|
faraday (>= 0.8.0)
|
||||||
http-cookie (~> 1.0.0)
|
http-cookie (>= 1.0.0)
|
||||||
faraday-em_http (1.0.0)
|
faraday-em_http (1.0.0)
|
||||||
faraday-em_synchrony (1.0.0)
|
faraday-em_synchrony (1.0.1)
|
||||||
faraday-excon (1.1.0)
|
faraday-excon (1.1.0)
|
||||||
faraday-httpclient (1.0.1)
|
faraday-httpclient (1.0.1)
|
||||||
faraday-multipart (1.1.0)
|
faraday-multipart (1.2.0)
|
||||||
multipart-post (~> 2.0)
|
multipart-post (~> 2.0)
|
||||||
faraday-net_http (1.0.2)
|
faraday-net_http (1.0.2)
|
||||||
faraday-net_http_persistent (1.2.0)
|
faraday-net_http_persistent (1.2.0)
|
||||||
faraday-patron (1.0.0)
|
faraday-patron (1.0.0)
|
||||||
faraday-rack (1.0.0)
|
faraday-rack (1.0.0)
|
||||||
faraday-retry (1.0.3)
|
faraday-retry (1.0.4)
|
||||||
faraday_middleware (1.2.1)
|
faraday_middleware (1.2.1)
|
||||||
faraday (~> 1.0)
|
faraday (~> 1.0)
|
||||||
fastimage (2.4.0)
|
fastimage (2.4.1)
|
||||||
fastlane (2.227.0)
|
fastlane (2.234.0)
|
||||||
CFPropertyList (>= 2.3, < 4.0.0)
|
CFPropertyList (>= 2.3, < 5.0.0)
|
||||||
|
abbrev (~> 0.1)
|
||||||
addressable (>= 2.8, < 3.0.0)
|
addressable (>= 2.8, < 3.0.0)
|
||||||
artifactory (~> 3.0)
|
artifactory (~> 3.0)
|
||||||
aws-sdk-s3 (~> 1.0)
|
aws-sdk-s3 (~> 1.197)
|
||||||
babosa (>= 1.0.3, < 2.0.0)
|
babosa (>= 1.0.3, < 2.0.0)
|
||||||
bundler (>= 1.12.0, < 3.0.0)
|
base64 (~> 0.2)
|
||||||
|
benchmark (>= 0.1.0)
|
||||||
|
bundler (>= 1.17.3, < 5.0.0)
|
||||||
colored (~> 1.2)
|
colored (~> 1.2)
|
||||||
commander (~> 4.6)
|
commander (~> 4.6)
|
||||||
|
csv (~> 3.3)
|
||||||
dotenv (>= 2.1.1, < 3.0.0)
|
dotenv (>= 2.1.1, < 3.0.0)
|
||||||
emoji_regex (>= 0.1, < 4.0)
|
emoji_regex (>= 0.1, < 4.0)
|
||||||
excon (>= 0.71.0, < 1.0.0)
|
excon (>= 0.71.0, < 1.0.0)
|
||||||
|
|
@ -87,20 +92,24 @@ GEM
|
||||||
faraday-cookie_jar (~> 0.0.6)
|
faraday-cookie_jar (~> 0.0.6)
|
||||||
faraday_middleware (~> 1.0)
|
faraday_middleware (~> 1.0)
|
||||||
fastimage (>= 2.1.0, < 3.0.0)
|
fastimage (>= 2.1.0, < 3.0.0)
|
||||||
fastlane-sirp (>= 1.0.0)
|
fastlane-sirp (>= 1.1.0)
|
||||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||||
google-apis-androidpublisher_v3 (~> 0.3)
|
google-apis-androidpublisher_v3 (~> 0.3)
|
||||||
google-apis-playcustomapp_v1 (~> 0.1)
|
google-apis-playcustomapp_v1 (~> 0.1)
|
||||||
google-cloud-env (>= 1.6.0, < 2.0.0)
|
google-cloud-env (>= 1.6.0, <= 2.1.1)
|
||||||
google-cloud-storage (~> 1.31)
|
google-cloud-storage (~> 1.31)
|
||||||
highline (~> 2.0)
|
highline (~> 2.0)
|
||||||
http-cookie (~> 1.0.5)
|
http-cookie (~> 1.0.5)
|
||||||
json (< 3.0.0)
|
json (< 3.0.0)
|
||||||
jwt (>= 2.1.0, < 3)
|
jwt (>= 2.1.0, < 3)
|
||||||
|
logger (>= 1.6, < 2.0)
|
||||||
mini_magick (>= 4.9.4, < 5.0.0)
|
mini_magick (>= 4.9.4, < 5.0.0)
|
||||||
multipart-post (>= 2.0.0, < 3.0.0)
|
multipart-post (>= 2.0.0, < 3.0.0)
|
||||||
|
mutex_m (~> 0.3)
|
||||||
naturally (~> 2.2)
|
naturally (~> 2.2)
|
||||||
|
nkf (~> 0.2)
|
||||||
optparse (>= 0.1.1, < 1.0.0)
|
optparse (>= 0.1.1, < 1.0.0)
|
||||||
|
ostruct (>= 0.1.0)
|
||||||
plist (>= 3.1.0, < 4.0.0)
|
plist (>= 3.1.0, < 4.0.0)
|
||||||
rubyzip (>= 2.0.0, < 3.0.0)
|
rubyzip (>= 2.0.0, < 3.0.0)
|
||||||
security (= 0.1.5)
|
security (= 0.1.5)
|
||||||
|
|
@ -111,43 +120,44 @@ GEM
|
||||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||||
word_wrap (~> 1.0.0)
|
word_wrap (~> 1.0.0)
|
||||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||||
xcpretty (~> 0.4.0)
|
xcpretty (~> 0.4.1)
|
||||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||||
fastlane-sirp (1.0.0)
|
fastlane-sirp (1.1.0)
|
||||||
sysrandom (~> 1.0)
|
|
||||||
gh_inspector (1.1.3)
|
gh_inspector (1.1.3)
|
||||||
google-apis-androidpublisher_v3 (0.54.0)
|
google-apis-androidpublisher_v3 (0.102.0)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (0.11.3)
|
google-apis-core (0.18.0)
|
||||||
addressable (~> 2.5, >= 2.5.1)
|
addressable (~> 2.5, >= 2.5.1)
|
||||||
googleauth (>= 0.16.2, < 2.a)
|
googleauth (~> 1.9)
|
||||||
httpclient (>= 2.8.1, < 3.a)
|
httpclient (>= 2.8.3, < 3.a)
|
||||||
mini_mime (~> 1.0)
|
mini_mime (~> 1.0)
|
||||||
|
mutex_m
|
||||||
representable (~> 3.0)
|
representable (~> 3.0)
|
||||||
retriable (>= 2.0, < 4.a)
|
retriable (>= 2.0, < 4.a)
|
||||||
rexml
|
google-apis-iamcredentials_v1 (0.27.0)
|
||||||
google-apis-iamcredentials_v1 (0.17.0)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-apis-playcustomapp_v1 (0.17.0)
|
||||||
google-apis-playcustomapp_v1 (0.13.0)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-apis-storage_v1 (0.63.0)
|
||||||
google-apis-storage_v1 (0.29.0)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-cloud-core (1.9.0)
|
||||||
google-cloud-core (1.6.1)
|
|
||||||
google-cloud-env (>= 1.0, < 3.a)
|
google-cloud-env (>= 1.0, < 3.a)
|
||||||
google-cloud-errors (~> 1.0)
|
google-cloud-errors (~> 1.0)
|
||||||
google-cloud-env (1.6.0)
|
google-cloud-env (2.1.1)
|
||||||
faraday (>= 0.17.3, < 3.0)
|
faraday (>= 1.0, < 3.a)
|
||||||
google-cloud-errors (1.3.1)
|
google-cloud-errors (1.6.0)
|
||||||
google-cloud-storage (1.45.0)
|
google-cloud-storage (1.61.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
digest-crc (~> 0.4)
|
digest-crc (~> 0.4)
|
||||||
google-apis-iamcredentials_v1 (~> 0.1)
|
google-apis-core (>= 0.18, < 2)
|
||||||
google-apis-storage_v1 (~> 0.29.0)
|
google-apis-iamcredentials_v1 (~> 0.18)
|
||||||
|
google-apis-storage_v1 (>= 0.42)
|
||||||
google-cloud-core (~> 1.6)
|
google-cloud-core (~> 1.6)
|
||||||
googleauth (>= 0.16.2, < 2.a)
|
googleauth (~> 1.9)
|
||||||
mini_mime (~> 1.0)
|
mini_mime (~> 1.0)
|
||||||
googleauth (1.8.1)
|
googleauth (1.11.2)
|
||||||
faraday (>= 0.17.3, < 3.a)
|
faraday (>= 1.0, < 3.a)
|
||||||
|
google-cloud-env (~> 2.1)
|
||||||
jwt (>= 1.4, < 3.0)
|
jwt (>= 1.4, < 3.0)
|
||||||
multi_json (~> 1.11)
|
multi_json (~> 1.11)
|
||||||
os (>= 0.9, < 2.0)
|
os (>= 0.9, < 2.0)
|
||||||
|
|
@ -158,42 +168,41 @@ GEM
|
||||||
httpclient (2.9.0)
|
httpclient (2.9.0)
|
||||||
mutex_m
|
mutex_m
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.7.6)
|
json (2.19.9)
|
||||||
jwt (2.10.1)
|
jwt (2.10.3)
|
||||||
base64
|
base64
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
mini_magick (4.13.2)
|
mini_magick (4.13.2)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
multi_json (1.15.0)
|
multi_json (1.21.1)
|
||||||
multipart-post (2.4.1)
|
multipart-post (2.4.1)
|
||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
nanaimo (0.4.0)
|
nanaimo (0.4.0)
|
||||||
naturally (2.2.1)
|
naturally (2.3.0)
|
||||||
nkf (0.2.0)
|
nkf (0.3.0)
|
||||||
optparse (0.6.0)
|
optparse (0.8.1)
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
|
ostruct (0.6.3)
|
||||||
plist (3.7.2)
|
plist (3.7.2)
|
||||||
public_suffix (5.1.1)
|
public_suffix (7.0.5)
|
||||||
rake (13.2.1)
|
rake (13.4.2)
|
||||||
representable (3.2.0)
|
representable (3.2.0)
|
||||||
declarative (< 0.1.0)
|
declarative (< 0.1.0)
|
||||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||||
uber (< 0.2.0)
|
uber (< 0.2.0)
|
||||||
retriable (3.1.2)
|
retriable (3.8.0)
|
||||||
rexml (3.4.1)
|
rexml (3.4.4)
|
||||||
rouge (3.28.0)
|
rouge (3.28.0)
|
||||||
ruby2_keywords (0.0.5)
|
ruby2_keywords (0.0.5)
|
||||||
rubyzip (2.4.1)
|
rubyzip (2.4.1)
|
||||||
security (0.1.5)
|
security (0.1.5)
|
||||||
signet (0.18.0)
|
signet (0.22.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
faraday (>= 0.17.5, < 3.a)
|
faraday (>= 0.17.5, < 3.a)
|
||||||
jwt (>= 1.5, < 3.0)
|
jwt (>= 1.5, < 4.0)
|
||||||
multi_json (~> 1.10)
|
|
||||||
simctl (1.6.10)
|
simctl (1.6.10)
|
||||||
CFPropertyList
|
CFPropertyList
|
||||||
naturally
|
naturally
|
||||||
sysrandom (1.0.5)
|
|
||||||
terminal-notifier (2.0.0)
|
terminal-notifier (2.0.0)
|
||||||
terminal-table (3.0.2)
|
terminal-table (3.0.2)
|
||||||
unicode-display_width (>= 1.1.1, < 3)
|
unicode-display_width (>= 1.1.1, < 3)
|
||||||
|
|
@ -203,7 +212,6 @@ GEM
|
||||||
tty-spinner (0.9.3)
|
tty-spinner (0.9.3)
|
||||||
tty-cursor (~> 0.7)
|
tty-cursor (~> 0.7)
|
||||||
uber (0.1.0)
|
uber (0.1.0)
|
||||||
unf (0.2.0)
|
|
||||||
unicode-display_width (2.6.0)
|
unicode-display_width (2.6.0)
|
||||||
word_wrap (1.0.0)
|
word_wrap (1.0.0)
|
||||||
xcodeproj (1.27.0)
|
xcodeproj (1.27.0)
|
||||||
|
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
plugins {
|
|
||||||
id "com.android.application"
|
|
||||||
// START: FlutterFire Configuration
|
|
||||||
id 'com.google.gms.google-services'
|
|
||||||
id 'com.google.firebase.firebase-perf'
|
|
||||||
id 'com.google.firebase.crashlytics'
|
|
||||||
// END: FlutterFire Configuration
|
|
||||||
id "kotlin-android"
|
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
|
||||||
id "dev.flutter.flutter-gradle-plugin"
|
|
||||||
}
|
|
||||||
|
|
||||||
def keystoreProperties = new Properties()
|
|
||||||
def keystorePropertiesFile = rootProject.file("key.properties")
|
|
||||||
if (keystorePropertiesFile.exists()) {
|
|
||||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "com.tommychow.frosty"
|
|
||||||
compileSdk = flutter.compileSdkVersion
|
|
||||||
ndkVersion = flutter.ndkVersion
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
|
||||||
targetCompatibility = JavaVersion.VERSION_1_8
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = JavaVersion.VERSION_1_8
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId = "com.tommychow.frosty"
|
|
||||||
// You can update the following values to match your application needs.
|
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
|
||||||
minSdk = flutter.minSdkVersion
|
|
||||||
targetSdk = flutter.targetSdkVersion
|
|
||||||
versionCode = flutter.versionCode
|
|
||||||
versionName = flutter.versionName
|
|
||||||
}
|
|
||||||
|
|
||||||
signingConfigs {
|
|
||||||
release {
|
|
||||||
keyAlias keystoreProperties["keyAlias"]
|
|
||||||
keyPassword keystoreProperties["keyPassword"]
|
|
||||||
storeFile keystoreProperties["storeFile"] ? file(keystoreProperties["storeFile"]) : null
|
|
||||||
storePassword keystoreProperties["storePassword"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
signingConfig signingConfigs.release
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
flutter {
|
|
||||||
source = "../.."
|
|
||||||
}
|
|
||||||
70
android/app/build.gradle.kts
Normal file
70
android/app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("com.google.gms.google-services")
|
||||||
|
id("com.google.firebase.firebase-perf")
|
||||||
|
id("com.google.firebase.crashlytics")
|
||||||
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
|
}
|
||||||
|
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.tommychow.frosty"
|
||||||
|
compileSdk = flutter.compileSdkVersion
|
||||||
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.tommychow.frosty"
|
||||||
|
minSdk = flutter.minSdkVersion
|
||||||
|
targetSdk = flutter.targetSdkVersion
|
||||||
|
versionCode = flutter.versionCode
|
||||||
|
versionName = flutter.versionName
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
keyAlias = keystoreProperties["keyAlias"] as String?
|
||||||
|
keyPassword = keystoreProperties["keyPassword"] as String?
|
||||||
|
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
|
||||||
|
storePassword = keystoreProperties["storePassword"] as String?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
// Use the real upload key when key.properties is present (release
|
||||||
|
// workflows create it from secrets). Allow debug signing only when a CI
|
||||||
|
// compile-only check explicitly opts in via ALLOW_DEBUG_SIGNED_RELEASE;
|
||||||
|
// otherwise fail loudly so a real release can never be silently
|
||||||
|
// debug-signed if the keystore step is ever missing.
|
||||||
|
signingConfig = when {
|
||||||
|
keystorePropertiesFile.exists() -> signingConfigs.getByName("release")
|
||||||
|
System.getenv("ALLOW_DEBUG_SIGNED_RELEASE") != null -> signingConfigs.getByName("debug")
|
||||||
|
else -> error("key.properties missing — set ALLOW_DEBUG_SIGNED_RELEASE=1 for compile-only builds")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flutter {
|
||||||
|
source = "../.."
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,8 @@
|
||||||
android:label="Frosty"
|
android:label="Frosty"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:allowBackup="false">
|
android:allowBackup="false"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|
@ -36,10 +37,16 @@
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="https"/>
|
||||||
|
<data android:host="twitch.tv" />
|
||||||
|
<data android:host="www.twitch.tv" />
|
||||||
|
<data android:host="m.twitch.tv" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<meta-data
|
|
||||||
android:name="io.flutter.embedding.android.EnableImpeller"
|
|
||||||
android:value="false" />
|
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package com.tommychow.frosty
|
||||||
|
|
||||||
|
import android.webkit.CookieManager
|
||||||
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
|
import io.flutter.plugin.common.MethodCall
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
|
class CookieExtractorPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||||
|
private lateinit var channel: MethodChannel
|
||||||
|
|
||||||
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
channel = MethodChannel(binding.binaryMessenger, "frosty/cookie_extractor")
|
||||||
|
channel.setMethodCallHandler(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
if (call.method == "extractTwitchAuthToken") {
|
||||||
|
val cookies = CookieManager.getInstance().getCookie("https://twitch.tv")
|
||||||
|
val authToken = cookies?.split(";")
|
||||||
|
?.map { it.trim() }
|
||||||
|
?.firstOrNull { it.startsWith("auth-token=") }
|
||||||
|
?.substringAfter("auth-token=")
|
||||||
|
result.success(authToken)
|
||||||
|
} else {
|
||||||
|
result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
channel.setMethodCallHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
package com.tommychow.frosty
|
package com.tommychow.frosty
|
||||||
|
|
||||||
import cl.puntito.simple_pip_mode.PipCallbackHelperActivityWrapper
|
import cl.puntito.simple_pip_mode.PipCallbackHelperActivityWrapper
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
|
||||||
class MainActivity: PipCallbackHelperActivityWrapper() {
|
class MainActivity: PipCallbackHelperActivityWrapper() {
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
flutterEngine.plugins.add(CookieExtractorPlugin())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
<item name="android:forceDarkAllowed">false</item>
|
<item name="android:forceDarkAllowed">false</item>
|
||||||
<item name="android:windowFullscreen">false</item>
|
<item name="android:windowFullscreen">false</item>
|
||||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
</style>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
<item name="android:forceDarkAllowed">false</item>
|
<item name="android:forceDarkAllowed">false</item>
|
||||||
<item name="android:windowFullscreen">false</item>
|
<item name="android:windowFullscreen">false</item>
|
||||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
</style>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
<item name="android:forceDarkAllowed">false</item>
|
<item name="android:forceDarkAllowed">false</item>
|
||||||
<item name="android:windowFullscreen">false</item>
|
<item name="android:windowFullscreen">false</item>
|
||||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
</style>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
<item name="android:forceDarkAllowed">false</item>
|
<item name="android:forceDarkAllowed">false</item>
|
||||||
<item name="android:windowFullscreen">false</item>
|
<item name="android:windowFullscreen">false</item>
|
||||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
</style>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
|
|
||||||
9
android/app/src/main/res/xml/data_extraction_rules.xml
Normal file
9
android/app/src/main/res/xml/data_extraction_rules.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<exclude domain="root" path="."/>
|
||||||
|
</cloud-backup>
|
||||||
|
<device-transfer>
|
||||||
|
<exclude domain="root" path="."/>
|
||||||
|
</device-transfer>
|
||||||
|
</data-extraction-rules>
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
allprojects {
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rootProject.buildDir = "../build"
|
|
||||||
subprojects {
|
|
||||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
|
||||||
}
|
|
||||||
subprojects {
|
|
||||||
project.evaluationDependsOn(":app")
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.register("clean", Delete) {
|
|
||||||
delete rootProject.buildDir
|
|
||||||
}
|
|
||||||
37
android/build.gradle.kts
Normal file
37
android/build.gradle.kts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import com.android.build.api.variant.LibraryAndroidComponentsExtension
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val newBuildDir: Directory =
|
||||||
|
rootProject.layout.buildDirectory
|
||||||
|
.dir("../../build")
|
||||||
|
.get()
|
||||||
|
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||||
|
|
||||||
|
subprojects {
|
||||||
|
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||||
|
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||||
|
}
|
||||||
|
subprojects {
|
||||||
|
project.evaluationDependsOn(":app")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AGP 9 requires each library to compile against at least the compileSdk of
|
||||||
|
// its dependencies. Several Flutter plugins still pin compileSdk 33/34.
|
||||||
|
// finalizeDsl runs after the plugin's android {} block. Does not change minSdk.
|
||||||
|
subprojects {
|
||||||
|
pluginManager.withPlugin("com.android.library") {
|
||||||
|
extensions.configure<LibraryAndroidComponentsExtension>("androidComponents") {
|
||||||
|
finalizeDsl { it.compileSdk = 36 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register<Delete>("clean") {
|
||||||
|
delete(rootProject.layout.buildDirectory)
|
||||||
|
}
|
||||||
|
|
@ -17,11 +17,14 @@ default_platform(:android)
|
||||||
|
|
||||||
platform :android do
|
platform :android do
|
||||||
desc "Deploy a new version to the Google Play"
|
desc "Deploy a new version to the Google Play"
|
||||||
lane :deploy do
|
lane :deploy do |options|
|
||||||
# Upload to the play store ussing the provided credentials and AAB from flutter build.
|
# Upload to the Play Store using the provided credentials and AAB from flutter build.
|
||||||
|
# With dry_run:true (release dry-run workflow), validate_only authenticates and
|
||||||
|
# validates the AAB against Google Play without committing the release.
|
||||||
upload_to_play_store(
|
upload_to_play_store(
|
||||||
json_key_data: Base64.decode64(ENV["PLAY_STORE_CREDENTIALS"]),
|
json_key_data: Base64.decode64(ENV["PLAY_STORE_CREDENTIALS"]),
|
||||||
aab: "../build/app/outputs/bundle/release/app-release.aab"
|
aab: "../build/app/outputs/bundle/release/app-release.aab",
|
||||||
|
validate_only: options[:dry_run] || false
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Watch and chat on Twitch with your favorite emotes from 7TV, BTTV, and FFZ!
|
Watch live streams and see community emotes in chat.
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Frosty for Twitch
|
Frosty
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,8 @@
|
||||||
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
org.gradle.caching=true
|
||||||
|
org.gradle.parallel=true
|
||||||
|
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||||
|
android.builtInKotlin=false
|
||||||
|
# This newDsl flag was added automatically by Flutter migrator
|
||||||
|
android.newDsl=false
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
pluginManagement {
|
|
||||||
def flutterSdkPath = {
|
|
||||||
def properties = new Properties()
|
|
||||||
file("local.properties").withInputStream { properties.load(it) }
|
|
||||||
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
|
||||||
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
|
||||||
return flutterSdkPath
|
|
||||||
}()
|
|
||||||
|
|
||||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
|
||||||
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
gradlePluginPortal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
plugins {
|
|
||||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
|
||||||
id "com.android.application" version "8.2.1" apply false
|
|
||||||
id "org.jetbrains.kotlin.android" version "2.0.21" apply false
|
|
||||||
id "com.google.gms.google-services" version "4.3.15" apply false
|
|
||||||
id "com.google.firebase.firebase-perf" version "1.4.2" apply false
|
|
||||||
id "com.google.firebase.crashlytics" version "2.9.9" apply false
|
|
||||||
}
|
|
||||||
|
|
||||||
include ":app"
|
|
||||||
28
android/settings.gradle.kts
Normal file
28
android/settings.gradle.kts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
pluginManagement {
|
||||||
|
val flutterSdkPath = run {
|
||||||
|
val properties = java.util.Properties()
|
||||||
|
file("local.properties").inputStream().use { properties.load(it) }
|
||||||
|
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||||
|
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||||
|
flutterSdkPath
|
||||||
|
}
|
||||||
|
|
||||||
|
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
|
id("com.android.application") version "9.1.0" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
|
||||||
|
id("com.google.gms.google-services") version "4.4.4" apply false
|
||||||
|
id("com.google.firebase.firebase-perf") version "2.0.2" apply false
|
||||||
|
id("com.google.firebase.crashlytics") version "3.0.6" apply false
|
||||||
|
}
|
||||||
|
|
||||||
|
include(":app")
|
||||||
1119
assets/js/vaft.user.js
Normal file
1119
assets/js/vaft.user.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,47 +1,28 @@
|
||||||
## v4.9.3
|
## v5.3.1
|
||||||
|
|
||||||
May 26, 2025
|
October 12, 2026
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- Fix missing 7TV emotes on some channels
|
- Fixed the player getting stuck on a Source error after the screen turns off or the network switches — it now reloads automatically instead of waiting for a manual refresh.
|
||||||
- Fix sleep timer menu border styling
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v4.9.2
|
## v5.3.0
|
||||||
|
|
||||||
April 7, 2025
|
October 12, 2026
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Fix large cache size (by @Artiu on GitHub)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v4.9.1
|
|
||||||
|
|
||||||
April 4, 2025
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- [Android] Fix overlay latency and stream qualities, persist last chosen stream quality (by @Artiu on GitHub)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v4.9.0
|
|
||||||
|
|
||||||
December 30, 2024
|
|
||||||
|
|
||||||
### New Features
|
### New Features
|
||||||
|
|
||||||
- [Android] Added new "Use enhanced rendering" option in video settings
|
- Added a Follow / Unfollow button under the player (between video and chat) for live and offline channels.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v5.2.9
|
||||||
|
|
||||||
|
October 7, 2026
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- Fixed incorrect badge order and spacing for FFZ bots
|
- Fixed tag stream lists reshuffling when loading more pages by paginating with Twitch’s sorted tag directory (GQL) instead of scanning Helix.
|
||||||
- Fixed missing emotes on channels that don't have 7TV enabled
|
- Fixed follow/unfollow failing with an integrity check error while logged in by confirming via a short Twitch WebView when needed.
|
||||||
|
|
||||||
#### See all release notes at https://github.com/tommyxchow/frosty/releases
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
|
||||||
35
docs/DESIGN-adblock.md
Normal file
35
docs/DESIGN-adblock.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Design: Twitch ad blocking (vaft) in Frosty fork
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
|
||||||
|
Personal Forgejo fork of Frosty blocks Twitch live ads using [TwitchAdSolutions vaft](https://github.com/pixeltris/TwitchAdSolutions) ideas, adapted to Flutter.
|
||||||
|
|
||||||
|
## Upstream baseline
|
||||||
|
|
||||||
|
Frosty **did not** ship an adblocker. The native player only **detects** stitched ad breaks (`adBreakStream`) to avoid bad latency/stall recovery. Turbo/sub may get `hide_ads` via linked web GQL session — server-side, not client blocking.
|
||||||
|
|
||||||
|
## Strategy
|
||||||
|
|
||||||
|
### Native player (primary — this is the reliable path)
|
||||||
|
|
||||||
|
1. When `SettingsStore.blockTwitchAds` is true, request `PlaybackAccessToken` with `playerType=popout` (vaft `ForceAccessTokenPlayerType`) instead of `site`.
|
||||||
|
2. When the native plugin reports an ad break, resolve a backup Usher URL trying `embed` → `popout` → `autoplay`, preferring playlists whose media m3u8 does not contain `stitched`, then `loadUrl` that stream.
|
||||||
|
3. When the ad break ends, restore the primary popout URL.
|
||||||
|
4. Overlay shows “Blocking ads” while `_blockingAds` is true.
|
||||||
|
|
||||||
|
Implementation: `lib/apis/twitch_ad_solutions.dart`, `lib/apis/twitch_gql_api.dart`, `lib/screens/channel/video/native_video_store.dart`.
|
||||||
|
|
||||||
|
### WebView player (legacy, best-effort)
|
||||||
|
|
||||||
|
Inject vendored `assets/js/vaft.user.js` on page start/finish. Not true `@run-at document-start` on Android via `webview_flutter`, so native remains preferred. Enabling adblock in Settings forces native on.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
- `blockTwitchAds` (default `true`)
|
||||||
|
- Turning adblock on sets `useNativePlayer = true`
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Upstream PR
|
||||||
|
- Full browser Worker prototype rewriting on native
|
||||||
|
- Third-party CDN proxies unless player-type swap proves insufficient
|
||||||
45
docs/OBTAINIUM.md
Normal file
45
docs/OBTAINIUM.md
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Obtainium (Android)
|
||||||
|
|
||||||
|
Forgejo fork: [copilot/frosty](https://forgejo.fsociety00.cc/copilot/frosty) (**private**).
|
||||||
|
|
||||||
|
Releases: `.forgejo/workflows/release.yml` on runner `desktop-release` (tag `v*` or workflow_dispatch).
|
||||||
|
|
||||||
|
## Obtainium settings
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---|---|
|
||||||
|
| Source | Forgejo |
|
||||||
|
| URL | `https://forgejo.fsociety00.cc/api/v1/repos/copilot/frosty/releases` |
|
||||||
|
| APK filter | `frosty-arm64-v8a-v.*\.apk` |
|
||||||
|
| Trim version | `v?([0-9]+\.[0-9]+\.[0-9]+)` |
|
||||||
|
| Match group | `1` |
|
||||||
|
|
||||||
|
Repo is private → add a Forgejo API token in Obtainium (same PAT as other Newas apps).
|
||||||
|
|
||||||
|
Quick add:
|
||||||
|
|
||||||
|
```text
|
||||||
|
obtainium://add/https://forgejo.fsociety00.cc/api/v1/repos/copilot/frosty/releases
|
||||||
|
```
|
||||||
|
|
||||||
|
## Signing / Play Store conflict
|
||||||
|
|
||||||
|
Package id stays `com.tommychow.frosty`. Play Store Frosty and this fork use **different signing keys**, so Obtainium cannot update stock → fork in place. Uninstall the Play build first, then install from Obtainium.
|
||||||
|
|
||||||
|
## CI secrets (Forgejo repo)
|
||||||
|
|
||||||
|
| Secret | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `RELEASE_KEYSTORE_BASE64` | Same pattern as Flow / Finamp / Jellyfin forks |
|
||||||
|
| `STORE_PASSWORD` | Keystore password |
|
||||||
|
| `KEY_ALIAS` | Key alias |
|
||||||
|
| `KEY_PASSWORD` | Key password |
|
||||||
|
| `CLIENT_ID` | Twitch Helix app client id (`--dart-define`) |
|
||||||
|
| `SECRET` | Twitch Helix app secret (`--dart-define`) |
|
||||||
|
|
||||||
|
## Cut a release
|
||||||
|
|
||||||
|
1. Bump `version:` in `pubspec.yaml` (`x.y.z+build`) if needed.
|
||||||
|
2. Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z`
|
||||||
|
or run **Actions → Release APK → Run workflow**.
|
||||||
|
3. Install / update via Obtainium once the release asset appears.
|
||||||
57
docs/release.md
Normal file
57
docs/release.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# Release — version, notes, tag, then stores
|
||||||
|
|
||||||
|
Cut a GitHub + Play + App Store release from `main`. Not a changelog dump. Not a docs rewrite.
|
||||||
|
|
||||||
|
This playbook lives in the repo and loads only when you open this workspace and ask to release. It is not a global skill.
|
||||||
|
|
||||||
|
Flow: **preflight → bump + notes → commit on `main` → tag → wait for workflows → publish the GitHub draft.**
|
||||||
|
|
||||||
|
The tag is the mechanical trigger. This file is the judgment pass around it.
|
||||||
|
|
||||||
|
## Preflight
|
||||||
|
|
||||||
|
Main CI green on current `HEAD`. Then `gh workflow run "Release dry-run"` on that `main` — the monthly heartbeat is not enough after a toolchain bump.
|
||||||
|
|
||||||
|
Click any pending Apple agreement first (`developer.apple.com/account`, then App Store Connect Business) or the iOS job 403s. There is no API for that. On Play, glance Console Home and **Policy and programs > App content** (`Needs attention`); a DDA banner or new declaration can block the AAB the same way.
|
||||||
|
|
||||||
|
## Version
|
||||||
|
|
||||||
|
Release commit on `main` is only `pubspec.yaml` (`x.y.z+build`, bump both) and `assets/release-notes.md`. Message: `release vX.Y.Z`.
|
||||||
|
|
||||||
|
Features since last tag → minor. Fixes only → patch. Build number +1.
|
||||||
|
|
||||||
|
Do not put `AGENTS.md` or other docs in that commit.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Write for someone opening the app, not the commit log. Match the latest shipped version's voice (`Added` / `Improved` / `Fixed`). Skip CI, Flutter upgrades, codegen, and anything a user wouldn't notice; the GitHub compare link is the rest.
|
||||||
|
|
||||||
|
Keep about three most recent versions, consecutive. Drop from the oldest end. Never skip a version in the middle. GitHub releases keep the full history.
|
||||||
|
|
||||||
|
- Features that didn't exist last release. Improvements only for behavior that already shipped — gating a new feature (opt-in permissions, an Enable flow) is not an improvement.
|
||||||
|
- If you can't remember the user-facing symptom, it doesn't belong.
|
||||||
|
- `[Android]` / `[iOS]` on platform-only lines. Cluster them: one tagged line at the end of its section, two or more adjacent.
|
||||||
|
- Credit GitHub PR authors as `(by @user on GitHub)` on each bullet they landed. Not AI, not issue reporters. The GitHub release body strips ` on GitHub` as redundant.
|
||||||
|
|
||||||
|
Example shape (from v5.1.0): `Added a new experimental native video player option with quality selection…` not `Bumped better_native_video_player`.
|
||||||
|
|
||||||
|
## Tag
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "release vX.Y.Z"
|
||||||
|
git push origin main
|
||||||
|
git tag vX.Y.Z # lightweight, on that commit
|
||||||
|
git push origin vX.Y.Z # v*.*.* triggers both workflows
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not `gh release create`. **Draft GitHub release** attaches `frosty-vX.Y.Z.apk` and fills the body from this version's section in `assets/release-notes.md`. **Build and release Frosty** uploads the Play AAB and submits iOS for review (`automatic_release: true`).
|
||||||
|
|
||||||
|
When the draft has the APK:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh release edit vX.Y.Z --draft=false
|
||||||
|
```
|
||||||
|
|
||||||
|
The README latest-release badge stays stale until that publish. Store listing copy already points at GitHub — don't add per-version Play changelog files.
|
||||||
|
|
||||||
|
Do not push the tag until the release commit is on `origin/main`.
|
||||||
|
|
@ -20,7 +20,5 @@
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<key>MinimumOSVersion</key>
|
|
||||||
<string>12.0</string>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
151
ios/Gemfile.lock
151
ios/Gemfile.lock
|
|
@ -1,48 +1,49 @@
|
||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
CFPropertyList (3.0.7)
|
CFPropertyList (3.0.8)
|
||||||
base64
|
abbrev (0.1.2)
|
||||||
nkf
|
addressable (2.9.0)
|
||||||
rexml
|
public_suffix (>= 2.0.2, < 8.0)
|
||||||
addressable (2.8.7)
|
|
||||||
public_suffix (>= 2.0.2, < 7.0)
|
|
||||||
artifactory (3.0.17)
|
artifactory (3.0.17)
|
||||||
atomos (0.1.3)
|
atomos (0.1.3)
|
||||||
aws-eventstream (1.3.2)
|
aws-eventstream (1.4.0)
|
||||||
aws-partitions (1.1082.0)
|
aws-partitions (1.1260.0)
|
||||||
aws-sdk-core (3.222.1)
|
aws-sdk-core (3.252.0)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
aws-sigv4 (~> 1.9)
|
aws-sigv4 (~> 1.9)
|
||||||
base64
|
base64
|
||||||
|
bigdecimal
|
||||||
jmespath (~> 1, >= 1.6.1)
|
jmespath (~> 1, >= 1.6.1)
|
||||||
logger
|
logger
|
||||||
aws-sdk-kms (1.99.0)
|
aws-sdk-kms (1.129.0)
|
||||||
aws-sdk-core (~> 3, >= 3.216.0)
|
aws-sdk-core (~> 3, >= 3.248.0)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-s3 (1.183.0)
|
aws-sdk-s3 (1.225.1)
|
||||||
aws-sdk-core (~> 3, >= 3.216.0)
|
aws-sdk-core (~> 3, >= 3.248.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sigv4 (1.11.0)
|
aws-sigv4 (1.12.1)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
babosa (1.0.4)
|
babosa (1.0.4)
|
||||||
base64 (0.2.0)
|
base64 (0.3.0)
|
||||||
|
benchmark (0.5.0)
|
||||||
|
bigdecimal (4.1.2)
|
||||||
claide (1.1.0)
|
claide (1.1.0)
|
||||||
colored (1.2)
|
colored (1.2)
|
||||||
colored2 (3.1.2)
|
colored2 (3.1.2)
|
||||||
commander (4.6.0)
|
commander (4.6.0)
|
||||||
highline (~> 2.0.0)
|
highline (~> 2.0.0)
|
||||||
|
csv (3.3.5)
|
||||||
declarative (0.0.20)
|
declarative (0.0.20)
|
||||||
digest-crc (0.7.0)
|
digest-crc (0.7.0)
|
||||||
rake (>= 12.0.0, < 14.0.0)
|
rake (>= 12.0.0, < 14.0.0)
|
||||||
domain_name (0.5.20190701)
|
domain_name (0.6.20240107)
|
||||||
unf (>= 0.0.5, < 1.0.0)
|
|
||||||
dotenv (2.8.1)
|
dotenv (2.8.1)
|
||||||
emoji_regex (3.2.3)
|
emoji_regex (3.2.3)
|
||||||
excon (0.109.0)
|
excon (0.112.0)
|
||||||
faraday (1.10.4)
|
faraday (1.10.5)
|
||||||
faraday-em_http (~> 1.0)
|
faraday-em_http (~> 1.0)
|
||||||
faraday-em_synchrony (~> 1.0)
|
faraday-em_synchrony (~> 1.0)
|
||||||
faraday-excon (~> 1.1)
|
faraday-excon (~> 1.1)
|
||||||
|
|
@ -54,32 +55,36 @@ GEM
|
||||||
faraday-rack (~> 1.0)
|
faraday-rack (~> 1.0)
|
||||||
faraday-retry (~> 1.0)
|
faraday-retry (~> 1.0)
|
||||||
ruby2_keywords (>= 0.0.4)
|
ruby2_keywords (>= 0.0.4)
|
||||||
faraday-cookie_jar (0.0.7)
|
faraday-cookie_jar (0.0.8)
|
||||||
faraday (>= 0.8.0)
|
faraday (>= 0.8.0)
|
||||||
http-cookie (~> 1.0.0)
|
http-cookie (>= 1.0.0)
|
||||||
faraday-em_http (1.0.0)
|
faraday-em_http (1.0.0)
|
||||||
faraday-em_synchrony (1.0.0)
|
faraday-em_synchrony (1.0.1)
|
||||||
faraday-excon (1.1.0)
|
faraday-excon (1.1.0)
|
||||||
faraday-httpclient (1.0.1)
|
faraday-httpclient (1.0.1)
|
||||||
faraday-multipart (1.1.0)
|
faraday-multipart (1.2.0)
|
||||||
multipart-post (~> 2.0)
|
multipart-post (~> 2.0)
|
||||||
faraday-net_http (1.0.2)
|
faraday-net_http (1.0.2)
|
||||||
faraday-net_http_persistent (1.2.0)
|
faraday-net_http_persistent (1.2.0)
|
||||||
faraday-patron (1.0.0)
|
faraday-patron (1.0.0)
|
||||||
faraday-rack (1.0.0)
|
faraday-rack (1.0.0)
|
||||||
faraday-retry (1.0.3)
|
faraday-retry (1.0.4)
|
||||||
faraday_middleware (1.2.1)
|
faraday_middleware (1.2.1)
|
||||||
faraday (~> 1.0)
|
faraday (~> 1.0)
|
||||||
fastimage (2.4.0)
|
fastimage (2.4.1)
|
||||||
fastlane (2.227.0)
|
fastlane (2.234.0)
|
||||||
CFPropertyList (>= 2.3, < 4.0.0)
|
CFPropertyList (>= 2.3, < 5.0.0)
|
||||||
|
abbrev (~> 0.1)
|
||||||
addressable (>= 2.8, < 3.0.0)
|
addressable (>= 2.8, < 3.0.0)
|
||||||
artifactory (~> 3.0)
|
artifactory (~> 3.0)
|
||||||
aws-sdk-s3 (~> 1.0)
|
aws-sdk-s3 (~> 1.197)
|
||||||
babosa (>= 1.0.3, < 2.0.0)
|
babosa (>= 1.0.3, < 2.0.0)
|
||||||
bundler (>= 1.12.0, < 3.0.0)
|
base64 (~> 0.2)
|
||||||
|
benchmark (>= 0.1.0)
|
||||||
|
bundler (>= 1.17.3, < 5.0.0)
|
||||||
colored (~> 1.2)
|
colored (~> 1.2)
|
||||||
commander (~> 4.6)
|
commander (~> 4.6)
|
||||||
|
csv (~> 3.3)
|
||||||
dotenv (>= 2.1.1, < 3.0.0)
|
dotenv (>= 2.1.1, < 3.0.0)
|
||||||
emoji_regex (>= 0.1, < 4.0)
|
emoji_regex (>= 0.1, < 4.0)
|
||||||
excon (>= 0.71.0, < 1.0.0)
|
excon (>= 0.71.0, < 1.0.0)
|
||||||
|
|
@ -87,20 +92,24 @@ GEM
|
||||||
faraday-cookie_jar (~> 0.0.6)
|
faraday-cookie_jar (~> 0.0.6)
|
||||||
faraday_middleware (~> 1.0)
|
faraday_middleware (~> 1.0)
|
||||||
fastimage (>= 2.1.0, < 3.0.0)
|
fastimage (>= 2.1.0, < 3.0.0)
|
||||||
fastlane-sirp (>= 1.0.0)
|
fastlane-sirp (>= 1.1.0)
|
||||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||||
google-apis-androidpublisher_v3 (~> 0.3)
|
google-apis-androidpublisher_v3 (~> 0.3)
|
||||||
google-apis-playcustomapp_v1 (~> 0.1)
|
google-apis-playcustomapp_v1 (~> 0.1)
|
||||||
google-cloud-env (>= 1.6.0, < 2.0.0)
|
google-cloud-env (>= 1.6.0, <= 2.1.1)
|
||||||
google-cloud-storage (~> 1.31)
|
google-cloud-storage (~> 1.31)
|
||||||
highline (~> 2.0)
|
highline (~> 2.0)
|
||||||
http-cookie (~> 1.0.5)
|
http-cookie (~> 1.0.5)
|
||||||
json (< 3.0.0)
|
json (< 3.0.0)
|
||||||
jwt (>= 2.1.0, < 3)
|
jwt (>= 2.1.0, < 3)
|
||||||
|
logger (>= 1.6, < 2.0)
|
||||||
mini_magick (>= 4.9.4, < 5.0.0)
|
mini_magick (>= 4.9.4, < 5.0.0)
|
||||||
multipart-post (>= 2.0.0, < 3.0.0)
|
multipart-post (>= 2.0.0, < 3.0.0)
|
||||||
|
mutex_m (~> 0.3)
|
||||||
naturally (~> 2.2)
|
naturally (~> 2.2)
|
||||||
|
nkf (~> 0.2)
|
||||||
optparse (>= 0.1.1, < 1.0.0)
|
optparse (>= 0.1.1, < 1.0.0)
|
||||||
|
ostruct (>= 0.1.0)
|
||||||
plist (>= 3.1.0, < 4.0.0)
|
plist (>= 3.1.0, < 4.0.0)
|
||||||
rubyzip (>= 2.0.0, < 3.0.0)
|
rubyzip (>= 2.0.0, < 3.0.0)
|
||||||
security (= 0.1.5)
|
security (= 0.1.5)
|
||||||
|
|
@ -111,43 +120,44 @@ GEM
|
||||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||||
word_wrap (~> 1.0.0)
|
word_wrap (~> 1.0.0)
|
||||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||||
xcpretty (~> 0.4.0)
|
xcpretty (~> 0.4.1)
|
||||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||||
fastlane-sirp (1.0.0)
|
fastlane-sirp (1.1.0)
|
||||||
sysrandom (~> 1.0)
|
|
||||||
gh_inspector (1.1.3)
|
gh_inspector (1.1.3)
|
||||||
google-apis-androidpublisher_v3 (0.54.0)
|
google-apis-androidpublisher_v3 (0.102.0)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (0.11.3)
|
google-apis-core (0.18.0)
|
||||||
addressable (~> 2.5, >= 2.5.1)
|
addressable (~> 2.5, >= 2.5.1)
|
||||||
googleauth (>= 0.16.2, < 2.a)
|
googleauth (~> 1.9)
|
||||||
httpclient (>= 2.8.1, < 3.a)
|
httpclient (>= 2.8.3, < 3.a)
|
||||||
mini_mime (~> 1.0)
|
mini_mime (~> 1.0)
|
||||||
|
mutex_m
|
||||||
representable (~> 3.0)
|
representable (~> 3.0)
|
||||||
retriable (>= 2.0, < 4.a)
|
retriable (>= 2.0, < 4.a)
|
||||||
rexml
|
google-apis-iamcredentials_v1 (0.27.0)
|
||||||
google-apis-iamcredentials_v1 (0.17.0)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-apis-playcustomapp_v1 (0.17.0)
|
||||||
google-apis-playcustomapp_v1 (0.13.0)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-apis-storage_v1 (0.63.0)
|
||||||
google-apis-storage_v1 (0.29.0)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-cloud-core (1.9.0)
|
||||||
google-cloud-core (1.6.1)
|
|
||||||
google-cloud-env (>= 1.0, < 3.a)
|
google-cloud-env (>= 1.0, < 3.a)
|
||||||
google-cloud-errors (~> 1.0)
|
google-cloud-errors (~> 1.0)
|
||||||
google-cloud-env (1.6.0)
|
google-cloud-env (2.1.1)
|
||||||
faraday (>= 0.17.3, < 3.0)
|
faraday (>= 1.0, < 3.a)
|
||||||
google-cloud-errors (1.3.1)
|
google-cloud-errors (1.6.0)
|
||||||
google-cloud-storage (1.45.0)
|
google-cloud-storage (1.61.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
digest-crc (~> 0.4)
|
digest-crc (~> 0.4)
|
||||||
google-apis-iamcredentials_v1 (~> 0.1)
|
google-apis-core (>= 0.18, < 2)
|
||||||
google-apis-storage_v1 (~> 0.29.0)
|
google-apis-iamcredentials_v1 (~> 0.18)
|
||||||
|
google-apis-storage_v1 (>= 0.42)
|
||||||
google-cloud-core (~> 1.6)
|
google-cloud-core (~> 1.6)
|
||||||
googleauth (>= 0.16.2, < 2.a)
|
googleauth (~> 1.9)
|
||||||
mini_mime (~> 1.0)
|
mini_mime (~> 1.0)
|
||||||
googleauth (1.8.1)
|
googleauth (1.11.2)
|
||||||
faraday (>= 0.17.3, < 3.a)
|
faraday (>= 1.0, < 3.a)
|
||||||
|
google-cloud-env (~> 2.1)
|
||||||
jwt (>= 1.4, < 3.0)
|
jwt (>= 1.4, < 3.0)
|
||||||
multi_json (~> 1.11)
|
multi_json (~> 1.11)
|
||||||
os (>= 0.9, < 2.0)
|
os (>= 0.9, < 2.0)
|
||||||
|
|
@ -158,42 +168,41 @@ GEM
|
||||||
httpclient (2.9.0)
|
httpclient (2.9.0)
|
||||||
mutex_m
|
mutex_m
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.7.6)
|
json (2.19.9)
|
||||||
jwt (2.10.1)
|
jwt (2.10.3)
|
||||||
base64
|
base64
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
mini_magick (4.13.2)
|
mini_magick (4.13.2)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
multi_json (1.15.0)
|
multi_json (1.21.1)
|
||||||
multipart-post (2.4.1)
|
multipart-post (2.4.1)
|
||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
nanaimo (0.4.0)
|
nanaimo (0.4.0)
|
||||||
naturally (2.2.1)
|
naturally (2.3.0)
|
||||||
nkf (0.2.0)
|
nkf (0.3.0)
|
||||||
optparse (0.6.0)
|
optparse (0.8.1)
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
|
ostruct (0.6.3)
|
||||||
plist (3.7.2)
|
plist (3.7.2)
|
||||||
public_suffix (5.1.1)
|
public_suffix (7.0.5)
|
||||||
rake (13.2.1)
|
rake (13.4.2)
|
||||||
representable (3.2.0)
|
representable (3.2.0)
|
||||||
declarative (< 0.1.0)
|
declarative (< 0.1.0)
|
||||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||||
uber (< 0.2.0)
|
uber (< 0.2.0)
|
||||||
retriable (3.1.2)
|
retriable (3.8.0)
|
||||||
rexml (3.4.1)
|
rexml (3.4.4)
|
||||||
rouge (3.28.0)
|
rouge (3.28.0)
|
||||||
ruby2_keywords (0.0.5)
|
ruby2_keywords (0.0.5)
|
||||||
rubyzip (2.4.1)
|
rubyzip (2.4.1)
|
||||||
security (0.1.5)
|
security (0.1.5)
|
||||||
signet (0.18.0)
|
signet (0.22.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
faraday (>= 0.17.5, < 3.a)
|
faraday (>= 0.17.5, < 3.a)
|
||||||
jwt (>= 1.5, < 3.0)
|
jwt (>= 1.5, < 4.0)
|
||||||
multi_json (~> 1.10)
|
|
||||||
simctl (1.6.10)
|
simctl (1.6.10)
|
||||||
CFPropertyList
|
CFPropertyList
|
||||||
naturally
|
naturally
|
||||||
sysrandom (1.0.5)
|
|
||||||
terminal-notifier (2.0.0)
|
terminal-notifier (2.0.0)
|
||||||
terminal-table (3.0.2)
|
terminal-table (3.0.2)
|
||||||
unicode-display_width (>= 1.1.1, < 3)
|
unicode-display_width (>= 1.1.1, < 3)
|
||||||
|
|
@ -203,7 +212,6 @@ GEM
|
||||||
tty-spinner (0.9.3)
|
tty-spinner (0.9.3)
|
||||||
tty-cursor (~> 0.7)
|
tty-cursor (~> 0.7)
|
||||||
uber (0.1.0)
|
uber (0.1.0)
|
||||||
unf (0.2.0)
|
|
||||||
unicode-display_width (2.6.0)
|
unicode-display_width (2.6.0)
|
||||||
word_wrap (1.0.0)
|
word_wrap (1.0.0)
|
||||||
xcodeproj (1.27.0)
|
xcodeproj (1.27.0)
|
||||||
|
|
@ -225,6 +233,7 @@ PLATFORMS
|
||||||
universal-darwin-24
|
universal-darwin-24
|
||||||
x86_64-darwin-20
|
x86_64-darwin-20
|
||||||
x86_64-darwin-21
|
x86_64-darwin-21
|
||||||
|
x86_64-linux
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
fastlane
|
fastlane
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# Uncomment this line to define a global platform for your project
|
# Uncomment this line to define a global platform for your project
|
||||||
# platform :ios, '12.0'
|
platform :ios, '15.0'
|
||||||
|
|
||||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||||
|
|
|
||||||
286
ios/Podfile.lock
286
ios/Podfile.lock
|
|
@ -1,306 +1,28 @@
|
||||||
PODS:
|
PODS:
|
||||||
- advanced_in_app_review (0.0.1):
|
- advanced_in_app_review (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- device_info_plus (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- Firebase/Analytics (11.10.0):
|
|
||||||
- Firebase/Core
|
|
||||||
- Firebase/Core (11.10.0):
|
|
||||||
- Firebase/CoreOnly
|
|
||||||
- FirebaseAnalytics (~> 11.10.0)
|
|
||||||
- Firebase/CoreOnly (11.10.0):
|
|
||||||
- FirebaseCore (~> 11.10.0)
|
|
||||||
- Firebase/Crashlytics (11.10.0):
|
|
||||||
- Firebase/CoreOnly
|
|
||||||
- FirebaseCrashlytics (~> 11.10.0)
|
|
||||||
- Firebase/Performance (11.10.0):
|
|
||||||
- Firebase/CoreOnly
|
|
||||||
- FirebasePerformance (~> 11.10.0)
|
|
||||||
- firebase_analytics (11.4.6):
|
|
||||||
- Firebase/Analytics (= 11.10.0)
|
|
||||||
- firebase_core
|
|
||||||
- Flutter
|
|
||||||
- firebase_core (3.13.1):
|
|
||||||
- Firebase/CoreOnly (= 11.10.0)
|
|
||||||
- Flutter
|
|
||||||
- firebase_crashlytics (4.3.6):
|
|
||||||
- Firebase/Crashlytics (= 11.10.0)
|
|
||||||
- firebase_core
|
|
||||||
- Flutter
|
|
||||||
- firebase_performance (0.10.1-6):
|
|
||||||
- Firebase/Performance (= 11.10.0)
|
|
||||||
- firebase_core
|
|
||||||
- Flutter
|
|
||||||
- FirebaseABTesting (11.10.0):
|
|
||||||
- FirebaseCore (~> 11.10.0)
|
|
||||||
- FirebaseAnalytics (11.10.0):
|
|
||||||
- FirebaseAnalytics/AdIdSupport (= 11.10.0)
|
|
||||||
- FirebaseCore (~> 11.10.0)
|
|
||||||
- FirebaseInstallations (~> 11.0)
|
|
||||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
|
|
||||||
- GoogleUtilities/MethodSwizzler (~> 8.0)
|
|
||||||
- GoogleUtilities/Network (~> 8.0)
|
|
||||||
- "GoogleUtilities/NSData+zlib (~> 8.0)"
|
|
||||||
- nanopb (~> 3.30910.0)
|
|
||||||
- FirebaseAnalytics/AdIdSupport (11.10.0):
|
|
||||||
- FirebaseCore (~> 11.10.0)
|
|
||||||
- FirebaseInstallations (~> 11.0)
|
|
||||||
- GoogleAppMeasurement (= 11.10.0)
|
|
||||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
|
|
||||||
- GoogleUtilities/MethodSwizzler (~> 8.0)
|
|
||||||
- GoogleUtilities/Network (~> 8.0)
|
|
||||||
- "GoogleUtilities/NSData+zlib (~> 8.0)"
|
|
||||||
- nanopb (~> 3.30910.0)
|
|
||||||
- FirebaseCore (11.10.0):
|
|
||||||
- FirebaseCoreInternal (~> 11.10.0)
|
|
||||||
- GoogleUtilities/Environment (~> 8.0)
|
|
||||||
- GoogleUtilities/Logger (~> 8.0)
|
|
||||||
- FirebaseCoreExtension (11.10.0):
|
|
||||||
- FirebaseCore (~> 11.10.0)
|
|
||||||
- FirebaseCoreInternal (11.10.0):
|
|
||||||
- "GoogleUtilities/NSData+zlib (~> 8.0)"
|
|
||||||
- FirebaseCrashlytics (11.10.0):
|
|
||||||
- FirebaseCore (~> 11.10.0)
|
|
||||||
- FirebaseInstallations (~> 11.0)
|
|
||||||
- FirebaseRemoteConfigInterop (~> 11.0)
|
|
||||||
- FirebaseSessions (~> 11.0)
|
|
||||||
- GoogleDataTransport (~> 10.0)
|
|
||||||
- GoogleUtilities/Environment (~> 8.0)
|
|
||||||
- nanopb (~> 3.30910.0)
|
|
||||||
- PromisesObjC (~> 2.4)
|
|
||||||
- FirebaseInstallations (11.10.0):
|
|
||||||
- FirebaseCore (~> 11.10.0)
|
|
||||||
- GoogleUtilities/Environment (~> 8.0)
|
|
||||||
- GoogleUtilities/UserDefaults (~> 8.0)
|
|
||||||
- PromisesObjC (~> 2.4)
|
|
||||||
- FirebasePerformance (11.10.0):
|
|
||||||
- FirebaseCore (~> 11.10.0)
|
|
||||||
- FirebaseInstallations (~> 11.0)
|
|
||||||
- FirebaseRemoteConfig (~> 11.0)
|
|
||||||
- FirebaseSessions (~> 11.0)
|
|
||||||
- GoogleDataTransport (~> 10.0)
|
|
||||||
- GoogleUtilities/Environment (~> 8.0)
|
|
||||||
- GoogleUtilities/MethodSwizzler (~> 8.0)
|
|
||||||
- GoogleUtilities/UserDefaults (~> 8.0)
|
|
||||||
- nanopb (~> 3.30910.0)
|
|
||||||
- FirebaseRemoteConfig (11.10.0):
|
|
||||||
- FirebaseABTesting (~> 11.0)
|
|
||||||
- FirebaseCore (~> 11.10.0)
|
|
||||||
- FirebaseInstallations (~> 11.0)
|
|
||||||
- FirebaseRemoteConfigInterop (~> 11.0)
|
|
||||||
- FirebaseSharedSwift (~> 11.0)
|
|
||||||
- GoogleUtilities/Environment (~> 8.0)
|
|
||||||
- "GoogleUtilities/NSData+zlib (~> 8.0)"
|
|
||||||
- FirebaseRemoteConfigInterop (11.13.0)
|
|
||||||
- FirebaseSessions (11.10.0):
|
|
||||||
- FirebaseCore (~> 11.10.0)
|
|
||||||
- FirebaseCoreExtension (~> 11.10.0)
|
|
||||||
- FirebaseInstallations (~> 11.0)
|
|
||||||
- GoogleDataTransport (~> 10.0)
|
|
||||||
- GoogleUtilities/Environment (~> 8.0)
|
|
||||||
- GoogleUtilities/UserDefaults (~> 8.0)
|
|
||||||
- nanopb (~> 3.30910.0)
|
|
||||||
- PromisesSwift (~> 2.1)
|
|
||||||
- FirebaseSharedSwift (11.13.0)
|
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
- flutter_native_splash (2.4.3):
|
|
||||||
- Flutter
|
|
||||||
- flutter_secure_storage (6.0.0):
|
- flutter_secure_storage (6.0.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
- GoogleAppMeasurement (11.10.0):
|
|
||||||
- GoogleAppMeasurement/AdIdSupport (= 11.10.0)
|
|
||||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
|
|
||||||
- GoogleUtilities/MethodSwizzler (~> 8.0)
|
|
||||||
- GoogleUtilities/Network (~> 8.0)
|
|
||||||
- "GoogleUtilities/NSData+zlib (~> 8.0)"
|
|
||||||
- nanopb (~> 3.30910.0)
|
|
||||||
- GoogleAppMeasurement/AdIdSupport (11.10.0):
|
|
||||||
- GoogleAppMeasurement/WithoutAdIdSupport (= 11.10.0)
|
|
||||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
|
|
||||||
- GoogleUtilities/MethodSwizzler (~> 8.0)
|
|
||||||
- GoogleUtilities/Network (~> 8.0)
|
|
||||||
- "GoogleUtilities/NSData+zlib (~> 8.0)"
|
|
||||||
- nanopb (~> 3.30910.0)
|
|
||||||
- GoogleAppMeasurement/WithoutAdIdSupport (11.10.0):
|
|
||||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
|
|
||||||
- GoogleUtilities/MethodSwizzler (~> 8.0)
|
|
||||||
- GoogleUtilities/Network (~> 8.0)
|
|
||||||
- "GoogleUtilities/NSData+zlib (~> 8.0)"
|
|
||||||
- nanopb (~> 3.30910.0)
|
|
||||||
- GoogleDataTransport (10.1.0):
|
|
||||||
- nanopb (~> 3.30910.0)
|
|
||||||
- PromisesObjC (~> 2.4)
|
|
||||||
- GoogleUtilities/AppDelegateSwizzler (8.1.0):
|
|
||||||
- GoogleUtilities/Environment
|
|
||||||
- GoogleUtilities/Logger
|
|
||||||
- GoogleUtilities/Network
|
|
||||||
- GoogleUtilities/Privacy
|
|
||||||
- GoogleUtilities/Environment (8.1.0):
|
|
||||||
- GoogleUtilities/Privacy
|
|
||||||
- GoogleUtilities/Logger (8.1.0):
|
|
||||||
- GoogleUtilities/Environment
|
|
||||||
- GoogleUtilities/Privacy
|
|
||||||
- GoogleUtilities/MethodSwizzler (8.1.0):
|
|
||||||
- GoogleUtilities/Logger
|
|
||||||
- GoogleUtilities/Privacy
|
|
||||||
- GoogleUtilities/Network (8.1.0):
|
|
||||||
- GoogleUtilities/Logger
|
|
||||||
- "GoogleUtilities/NSData+zlib"
|
|
||||||
- GoogleUtilities/Privacy
|
|
||||||
- GoogleUtilities/Reachability
|
|
||||||
- "GoogleUtilities/NSData+zlib (8.1.0)":
|
|
||||||
- GoogleUtilities/Privacy
|
|
||||||
- GoogleUtilities/Privacy (8.1.0)
|
|
||||||
- GoogleUtilities/Reachability (8.1.0):
|
|
||||||
- GoogleUtilities/Logger
|
|
||||||
- GoogleUtilities/Privacy
|
|
||||||
- GoogleUtilities/UserDefaults (8.1.0):
|
|
||||||
- GoogleUtilities/Logger
|
|
||||||
- GoogleUtilities/Privacy
|
|
||||||
- in_app_review (2.0.0):
|
|
||||||
- Flutter
|
|
||||||
- nanopb (3.30910.0):
|
|
||||||
- nanopb/decode (= 3.30910.0)
|
|
||||||
- nanopb/encode (= 3.30910.0)
|
|
||||||
- nanopb/decode (3.30910.0)
|
|
||||||
- nanopb/encode (3.30910.0)
|
|
||||||
- package_info_plus (0.4.5):
|
|
||||||
- Flutter
|
|
||||||
- path_provider_foundation (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- FlutterMacOS
|
|
||||||
- PromisesObjC (2.4.0)
|
|
||||||
- PromisesSwift (2.4.0):
|
|
||||||
- PromisesObjC (= 2.4.0)
|
|
||||||
- shared_preferences_foundation (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- FlutterMacOS
|
|
||||||
- sqflite_darwin (0.0.4):
|
|
||||||
- Flutter
|
|
||||||
- FlutterMacOS
|
|
||||||
- url_launcher_ios (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- wakelock_plus (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- webview_flutter_wkwebview (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- FlutterMacOS
|
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- advanced_in_app_review (from `.symlinks/plugins/advanced_in_app_review/ios`)
|
- advanced_in_app_review (from `.symlinks/plugins/advanced_in_app_review/ios`)
|
||||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
|
||||||
- firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
|
|
||||||
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
|
|
||||||
- firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`)
|
|
||||||
- firebase_performance (from `.symlinks/plugins/firebase_performance/ios`)
|
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
|
||||||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||||
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
|
|
||||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
|
||||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
|
||||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
|
||||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
|
||||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
|
||||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
|
||||||
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
|
|
||||||
|
|
||||||
SPEC REPOS:
|
|
||||||
trunk:
|
|
||||||
- Firebase
|
|
||||||
- FirebaseABTesting
|
|
||||||
- FirebaseAnalytics
|
|
||||||
- FirebaseCore
|
|
||||||
- FirebaseCoreExtension
|
|
||||||
- FirebaseCoreInternal
|
|
||||||
- FirebaseCrashlytics
|
|
||||||
- FirebaseInstallations
|
|
||||||
- FirebasePerformance
|
|
||||||
- FirebaseRemoteConfig
|
|
||||||
- FirebaseRemoteConfigInterop
|
|
||||||
- FirebaseSessions
|
|
||||||
- FirebaseSharedSwift
|
|
||||||
- GoogleAppMeasurement
|
|
||||||
- GoogleDataTransport
|
|
||||||
- GoogleUtilities
|
|
||||||
- nanopb
|
|
||||||
- PromisesObjC
|
|
||||||
- PromisesSwift
|
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
advanced_in_app_review:
|
advanced_in_app_review:
|
||||||
:path: ".symlinks/plugins/advanced_in_app_review/ios"
|
:path: ".symlinks/plugins/advanced_in_app_review/ios"
|
||||||
device_info_plus:
|
|
||||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
|
||||||
firebase_analytics:
|
|
||||||
:path: ".symlinks/plugins/firebase_analytics/ios"
|
|
||||||
firebase_core:
|
|
||||||
:path: ".symlinks/plugins/firebase_core/ios"
|
|
||||||
firebase_crashlytics:
|
|
||||||
:path: ".symlinks/plugins/firebase_crashlytics/ios"
|
|
||||||
firebase_performance:
|
|
||||||
:path: ".symlinks/plugins/firebase_performance/ios"
|
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
flutter_native_splash:
|
|
||||||
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
|
||||||
flutter_secure_storage:
|
flutter_secure_storage:
|
||||||
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
||||||
in_app_review:
|
|
||||||
:path: ".symlinks/plugins/in_app_review/ios"
|
|
||||||
package_info_plus:
|
|
||||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
|
||||||
path_provider_foundation:
|
|
||||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
|
||||||
shared_preferences_foundation:
|
|
||||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
|
||||||
sqflite_darwin:
|
|
||||||
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
|
||||||
url_launcher_ios:
|
|
||||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
|
||||||
wakelock_plus:
|
|
||||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
|
||||||
webview_flutter_wkwebview:
|
|
||||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
advanced_in_app_review: fe8fd50b8670cb3dbe67e4c26ca643f7aea8dbb8
|
advanced_in_app_review: daebe4c801525b756fe589de60fd327cb12f965c
|
||||||
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
|
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
|
||||||
Firebase: 1fe1c0a7d9aaea32efe01fbea5f0ebd8d70e53a2
|
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
||||||
firebase_analytics: 943aa4699bf6afe056a96991ba8ee34776323d4c
|
|
||||||
firebase_core: 3c2f323cae65c97a636a05a23b17730ef93df2cf
|
|
||||||
firebase_crashlytics: 6f5f71ba7ec7b6a0412c4ed076272643c434f69f
|
|
||||||
firebase_performance: da79b34e2753057f7028c5e41ac5d8b3c5398eb2
|
|
||||||
FirebaseABTesting: dfc10eb6cc08fe3b391ac9e5aa40396d43ea6675
|
|
||||||
FirebaseAnalytics: 4e42333f02cf78ed93703a5c36f36dd518aebdef
|
|
||||||
FirebaseCore: 8344daef5e2661eb004b177488d6f9f0f24251b7
|
|
||||||
FirebaseCoreExtension: 6f357679327f3614e995dc7cf3f2d600bdc774ac
|
|
||||||
FirebaseCoreInternal: ef4505d2afb1d0ebbc33162cb3795382904b5679
|
|
||||||
FirebaseCrashlytics: 84b073c997235740e6a951b7ee49608932877e5c
|
|
||||||
FirebaseInstallations: 9980995bdd06ec8081dfb6ab364162bdd64245c3
|
|
||||||
FirebasePerformance: f0bd14be05aaa1136cbb1c9aaaf87d213d0e6fbf
|
|
||||||
FirebaseRemoteConfig: 10695bc0ce3b103e3706a5578c43f2a9f69d5aaa
|
|
||||||
FirebaseRemoteConfigInterop: 7915cec47731a806cda541f90898ad0fab8f9f86
|
|
||||||
FirebaseSessions: 9b3b30947b97a15370e0902ee7a90f50ef60ead6
|
|
||||||
FirebaseSharedSwift: aca73668bc95e8efccb618e0167eab05d19d3a75
|
|
||||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
|
||||||
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
|
|
||||||
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
|
|
||||||
GoogleAppMeasurement: 36684bfb3ee034e2b42b4321eb19da3a1b81e65d
|
|
||||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
|
||||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
|
||||||
in_app_review: a31b5257259646ea78e0e35fc914979b0031d011
|
|
||||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
|
||||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
|
||||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
|
||||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
|
||||||
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
|
|
||||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
|
||||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
|
||||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
|
||||||
wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e
|
|
||||||
webview_flutter_wkwebview: a4af96a051138e28e29f60101d094683b9f82188
|
|
||||||
|
|
||||||
PODFILE CHECKSUM: c4c93c5f6502fe2754f48404d3594bf779584011
|
PODFILE CHECKSUM: 57c8aed26fba39d3ec9424816221f294a07c58eb
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,13 @@
|
||||||
3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E751BC99755AAC391DC0B29F /* Pods_Runner.framework */; };
|
3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E751BC99755AAC391DC0B29F /* Pods_Runner.framework */; };
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
|
F1C00CE01A2B3C4D00E5F6A8 /* CookieExtractorPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */; };
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
BE8095795EAFF58C08A43B48 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A68609F67C30CFEF267CD4D3 /* GoogleService-Info.plist */; };
|
BE8095795EAFF58C08A43B48 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A68609F67C30CFEF267CD4D3 /* GoogleService-Info.plist */; };
|
||||||
|
D4F5A60712340001ABCD1234 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */; };
|
||||||
|
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
|
@ -37,6 +40,7 @@
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieExtractorPlugin.swift; sourceTree = "<group>"; };
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||||
95D7E205EE04D01F6996ECFC /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
95D7E205EE04D01F6996ECFC /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
|
|
@ -48,7 +52,9 @@
|
||||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
A68609F67C30CFEF267CD4D3 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = "<group>"; };
|
A68609F67C30CFEF267CD4D3 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = "<group>"; };
|
||||||
CFF3F6A0DA5A835B219C2D3C /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
CFF3F6A0DA5A835B219C2D3C /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||||
E751BC99755AAC391DC0B29F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
E751BC99755AAC391DC0B29F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
|
@ -56,6 +62,7 @@
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||||
3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */,
|
3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|
@ -66,6 +73,7 @@
|
||||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||||
|
|
@ -104,7 +112,9 @@
|
||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||||
|
F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */,
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||||
|
D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */,
|
||||||
);
|
);
|
||||||
path = Runner;
|
path = Runner;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -131,6 +141,9 @@
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||||
|
packageProductDependencies = (
|
||||||
|
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||||
|
);
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
|
@ -157,6 +170,9 @@
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
97C146E61CF9000F007C117D /* Project object */ = {
|
97C146E61CF9000F007C117D /* Project object */ = {
|
||||||
|
packageReferences = (
|
||||||
|
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
||||||
|
);
|
||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
LastUpgradeCheck = 1510;
|
LastUpgradeCheck = 1510;
|
||||||
|
|
@ -196,6 +212,7 @@
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||||
BE8095795EAFF58C08A43B48 /* GoogleService-Info.plist in Resources */,
|
BE8095795EAFF58C08A43B48 /* GoogleService-Info.plist in Resources */,
|
||||||
|
D4F5A60712340001ABCD1234 /* PrivacyInfo.xcprivacy in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|
@ -242,7 +259,7 @@
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"$PODS_ROOT/FirebaseCrashlytics/upload-symbols\" --flutter-project \"$PROJECT_DIR/firebase_app_id_file.json\" ";
|
shellScript = "# Symbol upload only matters for builds that ship; Debug dSYMs are absent or empty and fail the tool.\nif [ \"$CONFIGURATION\" = \"Debug\" ]; then\n echo \"Skipping Crashlytics symbol upload for Debug build\"\n exit 0\nfi\n# Locate upload-symbols whether Firebase is integrated via CocoaPods or Swift Package Manager.\nBIN=\"$PODS_ROOT/FirebaseCrashlytics/upload-symbols\"\n[ -f \"$BIN\" ] || BIN=\"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/upload-symbols\"\n\"$BIN\" --flutter-project \"$PROJECT_DIR/firebase_app_id_file.json\"\n";
|
||||||
};
|
};
|
||||||
34CC65D58A1948B306772884 /* [CP] Embed Pods Frameworks */ = {
|
34CC65D58A1948B306772884 /* [CP] Embed Pods Frameworks */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
|
@ -300,6 +317,7 @@
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||||
|
F1C00CE01A2B3C4D00E5F6A8 /* CookieExtractorPlugin.swift in Sources */,
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|
@ -367,7 +385,7 @@
|
||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
|
|
@ -449,7 +467,7 @@
|
||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
|
@ -498,7 +516,7 @@
|
||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
|
|
@ -586,6 +604,18 @@
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||||
|
};
|
||||||
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = FlutterGeneratedPluginSwiftPackage;
|
||||||
|
};
|
||||||
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
{
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "abseil-cpp-binary",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/google/abseil-cpp-binary.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
|
||||||
|
"version" : "1.2024072200.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "app-check",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/google/app-check.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "bb4002485ff867768dec13bf904a2ddb050bd1b1",
|
||||||
|
"version" : "11.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "firebase-ios-sdk",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "33a468adfdb75b53f05a37e7c886ca7c962b5c17",
|
||||||
|
"version" : "12.17.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "google-ads-on-device-conversion-ios-sdk",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64",
|
||||||
|
"version" : "3.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "googleappmeasurement",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "fceaffa07d22dcd5624d3639fd970351a4a5ad8c",
|
||||||
|
"version" : "12.17.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "googledatatransport",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/google/GoogleDataTransport.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
|
||||||
|
"version" : "10.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "googleutilities",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/google/GoogleUtilities.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "c46e5f8b7c23265f17c24ca7f9fa1b13ded7a822",
|
||||||
|
"version" : "8.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "grpc-binary",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/google/grpc-binary.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
|
||||||
|
"version" : "1.69.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "gtm-session-fetcher",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/google/gtm-session-fetcher.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a",
|
||||||
|
"version" : "5.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "interop-ios-for-google-sdks",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
|
||||||
|
"version" : "101.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "leveldb",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/firebase/leveldb.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
|
||||||
|
"version" : "1.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "nanopb",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/firebase/nanopb.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "3851d94a41890dea16dc3db34caf60e585cb4163",
|
||||||
|
"version" : "2.30910.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "promises",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/google/promises.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
|
||||||
|
"version" : "2.4.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 2
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,24 @@
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES">
|
buildImplicitDependencies = "YES">
|
||||||
|
<PreActions>
|
||||||
|
<ExecutionAction
|
||||||
|
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||||
|
<ActionContent
|
||||||
|
title = "Run Prepare Flutter Framework Script"
|
||||||
|
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||||
|
<EnvironmentBuildable>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</EnvironmentBuildable>
|
||||||
|
</ActionContent>
|
||||||
|
</ExecutionAction>
|
||||||
|
</PreActions>
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
|
|
|
||||||
122
ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved
Normal file
122
ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
{
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "abseil-cpp-binary",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/google/abseil-cpp-binary.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
|
||||||
|
"version" : "1.2024072200.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "app-check",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/google/app-check.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "bb4002485ff867768dec13bf904a2ddb050bd1b1",
|
||||||
|
"version" : "11.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "firebase-ios-sdk",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "33a468adfdb75b53f05a37e7c886ca7c962b5c17",
|
||||||
|
"version" : "12.17.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "google-ads-on-device-conversion-ios-sdk",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64",
|
||||||
|
"version" : "3.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "googleappmeasurement",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "fceaffa07d22dcd5624d3639fd970351a4a5ad8c",
|
||||||
|
"version" : "12.17.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "googledatatransport",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/google/GoogleDataTransport.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
|
||||||
|
"version" : "10.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "googleutilities",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/google/GoogleUtilities.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "c46e5f8b7c23265f17c24ca7f9fa1b13ded7a822",
|
||||||
|
"version" : "8.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "grpc-binary",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/google/grpc-binary.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
|
||||||
|
"version" : "1.69.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "gtm-session-fetcher",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/google/gtm-session-fetcher.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a",
|
||||||
|
"version" : "5.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "interop-ios-for-google-sdks",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
|
||||||
|
"version" : "101.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "leveldb",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/firebase/leveldb.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
|
||||||
|
"version" : "1.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "nanopb",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/firebase/nanopb.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "3851d94a41890dea16dc3db34caf60e585cb4163",
|
||||||
|
"version" : "2.30910.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "promises",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/google/promises.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
|
||||||
|
"version" : "2.4.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 2
|
||||||
|
}
|
||||||
|
|
@ -2,12 +2,23 @@ import Flutter
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||||
|
guard let cookieExtractorRegistrar = engineBridge.pluginRegistry.registrar(
|
||||||
|
forPlugin: "CookieExtractorPlugin"
|
||||||
|
) else {
|
||||||
|
assertionFailure("Failed to create CookieExtractorPlugin registrar")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
CookieExtractorPlugin.register(with: cookieExtractorRegistrar)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
27
ios/Runner/CookieExtractorPlugin.swift
Normal file
27
ios/Runner/CookieExtractorPlugin.swift
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import Flutter
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
class CookieExtractorPlugin: NSObject, FlutterPlugin {
|
||||||
|
static func register(with registrar: FlutterPluginRegistrar) {
|
||||||
|
let channel = FlutterMethodChannel(
|
||||||
|
name: "frosty/cookie_extractor",
|
||||||
|
binaryMessenger: registrar.messenger()
|
||||||
|
)
|
||||||
|
let instance = CookieExtractorPlugin()
|
||||||
|
registrar.addMethodCallDelegate(instance, channel: channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||||
|
if call.method == "extractTwitchAuthToken" {
|
||||||
|
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
|
||||||
|
let authCookie = cookies.first {
|
||||||
|
$0.name == "auth-token" &&
|
||||||
|
($0.domain == "twitch.tv" || $0.domain.hasSuffix(".twitch.tv"))
|
||||||
|
}
|
||||||
|
result(authCookie?.value)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result(FlutterMethodNotImplemented)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -52,7 +52,32 @@
|
||||||
<true/>
|
<true/>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<false/>
|
||||||
|
<key>UISceneConfigurations</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIWindowSceneSessionRoleApplication</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UISceneClassName</key>
|
||||||
|
<string>UIWindowScene</string>
|
||||||
|
<key>UISceneDelegateClassName</key>
|
||||||
|
<string>FlutterSceneDelegate</string>
|
||||||
|
<key>UISceneConfigurationName</key>
|
||||||
|
<string>flutter</string>
|
||||||
|
<key>UISceneStoryboardFile</key>
|
||||||
|
<string>Main</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
<key>UIStatusBarHidden</key>
|
<key>UIStatusBarHidden</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
14
ios/Runner/PrivacyInfo.xcprivacy
Normal file
14
ios/Runner/PrivacyInfo.xcprivacy
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyTrackingDomains</key>
|
||||||
|
<array/>
|
||||||
|
<key>NSPrivacyAccessedAPITypes</key>
|
||||||
|
<array/>
|
||||||
|
<key>NSPrivacyCollectedDataTypes</key>
|
||||||
|
<array/>
|
||||||
|
<key>NSPrivacyTracking</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
|
@ -17,7 +17,9 @@ default_platform(:ios)
|
||||||
|
|
||||||
platform :ios do
|
platform :ios do
|
||||||
desc 'Push a new release build to the App Store'
|
desc 'Push a new release build to the App Store'
|
||||||
lane :release do
|
lane :release do |options|
|
||||||
|
dry_run = options[:dry_run] || false
|
||||||
|
|
||||||
# Setup for CI. Uses a keychain to prevent the CI from hanging due to password prompts.
|
# Setup for CI. Uses a keychain to prevent the CI from hanging due to password prompts.
|
||||||
setup_ci
|
setup_ci
|
||||||
|
|
||||||
|
|
@ -29,8 +31,9 @@ platform :ios do
|
||||||
is_key_content_base64: true
|
is_key_content_base64: true
|
||||||
)
|
)
|
||||||
|
|
||||||
# Manage code signing (with match).
|
# Manage code signing (with match). readonly on a dry run so we only verify the
|
||||||
sync_code_signing
|
# existing cert/profile resolve and never regenerate or mutate the certificates repo.
|
||||||
|
sync_code_signing(readonly: dry_run)
|
||||||
|
|
||||||
# Automatic signing is used during development, but fastlane needs to use manual signing.
|
# Automatic signing is used during development, but fastlane needs to use manual signing.
|
||||||
update_code_signing_settings(
|
update_code_signing_settings(
|
||||||
|
|
@ -45,6 +48,11 @@ platform :ios do
|
||||||
output_directory: '../build/ios/archive'
|
output_directory: '../build/ios/archive'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# On a dry run, stop here: the signed archive above exercises every step that
|
||||||
|
# actually fails (ASC key, match certs/profiles, signing, release build). The
|
||||||
|
# App Store upload itself is the only remaining unverified step.
|
||||||
|
next if dry_run
|
||||||
|
|
||||||
# Upload the app to the App Store.
|
# Upload the app to the App Store.
|
||||||
upload_to_app_store(
|
upload_to_app_store(
|
||||||
submit_for_review: true,
|
submit_for_review: true,
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
2025 Tommy Chow
|
2026 Tommy Chow
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Frosty for Twitch
|
Frosty
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
https://www.privacypolicies.com/live/095b4264-31b1-4280-837e-5843abde8b16
|
https://frostyapp.io/privacy
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Watch and chat on Twitch with your favorite emotes from 7TV, BTTV, and FFZ!
|
Watch live streams and see community emotes in chat.
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
See 7TV, BTTV, and FFZ emotes!
|
Community emotes in chat
|
||||||
|
|
|
||||||
269
lib/apis/base_api_client.dart
Normal file
269
lib/apis/base_api_client.dart
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||||
|
|
||||||
|
/// Common type aliases to reduce repetition
|
||||||
|
typedef JsonMap = Map<String, dynamic>;
|
||||||
|
typedef JsonList = List<dynamic>;
|
||||||
|
typedef QueryParams = Map<String, dynamic>;
|
||||||
|
typedef ApiHeaders = Map<String, String>;
|
||||||
|
|
||||||
|
/// Custom API exceptions for better error handling
|
||||||
|
class ApiException implements Exception {
|
||||||
|
final String message;
|
||||||
|
final int? statusCode;
|
||||||
|
final DioExceptionType? type;
|
||||||
|
|
||||||
|
const ApiException(this.message, [this.statusCode, this.type]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'ApiException: $message';
|
||||||
|
}
|
||||||
|
|
||||||
|
class NetworkException extends ApiException {
|
||||||
|
const NetworkException(String message)
|
||||||
|
: super(message, null, DioExceptionType.connectionError);
|
||||||
|
}
|
||||||
|
|
||||||
|
class TimeoutException extends ApiException {
|
||||||
|
const TimeoutException(String message)
|
||||||
|
: super(message, null, DioExceptionType.connectionTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ServerException extends ApiException {
|
||||||
|
const ServerException(String message, int statusCode)
|
||||||
|
: super(message, statusCode, DioExceptionType.badResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotFoundException extends ApiException {
|
||||||
|
const NotFoundException(String message)
|
||||||
|
: super(message, 404, DioExceptionType.badResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
class UnauthorizedException extends ApiException {
|
||||||
|
const UnauthorizedException(String message)
|
||||||
|
: super(message, 401, DioExceptionType.badResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Twitch GQL rejected the request for a missing/invalid Client-Integrity token.
|
||||||
|
class IntegrityCheckException extends ApiException {
|
||||||
|
const IntegrityCheckException([
|
||||||
|
String message = 'Twitch integrity check failed',
|
||||||
|
]) : super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base API client to eliminate code duplication across API services
|
||||||
|
abstract class BaseApiClient {
|
||||||
|
final Dio _dio;
|
||||||
|
final String baseUrl;
|
||||||
|
|
||||||
|
const BaseApiClient(this._dio, this.baseUrl);
|
||||||
|
|
||||||
|
/// Builds the full URL, handling both relative endpoints and full URLs
|
||||||
|
String _buildUrl(String endpoint) {
|
||||||
|
// If endpoint is already a full URL (starts with http:// or https://), use it as-is
|
||||||
|
if (endpoint.startsWith('http://') || endpoint.startsWith('https://')) {
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
// Otherwise, concatenate with base URL
|
||||||
|
return '$baseUrl$endpoint';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic GET request with common error handling
|
||||||
|
Future<T> get<T>(
|
||||||
|
String endpoint, {
|
||||||
|
QueryParams? queryParameters,
|
||||||
|
ApiHeaders? headers,
|
||||||
|
CancelToken? cancelToken,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final url = _buildUrl(endpoint);
|
||||||
|
final response = await _dio.get(
|
||||||
|
url,
|
||||||
|
queryParameters: queryParameters,
|
||||||
|
options: Options(headers: headers),
|
||||||
|
cancelToken: cancelToken,
|
||||||
|
);
|
||||||
|
return response.data as T;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _handleError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic POST request with common error handling
|
||||||
|
Future<T> post<T>(
|
||||||
|
String endpoint, {
|
||||||
|
dynamic data,
|
||||||
|
QueryParams? queryParameters,
|
||||||
|
ApiHeaders? headers,
|
||||||
|
CancelToken? cancelToken,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final url = _buildUrl(endpoint);
|
||||||
|
final response = await _dio.post(
|
||||||
|
url,
|
||||||
|
data: data,
|
||||||
|
queryParameters: queryParameters,
|
||||||
|
options: Options(headers: headers),
|
||||||
|
cancelToken: cancelToken,
|
||||||
|
);
|
||||||
|
return response.data as T;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _handleError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic PUT request with common error handling
|
||||||
|
Future<T> put<T>(
|
||||||
|
String endpoint, {
|
||||||
|
dynamic data,
|
||||||
|
QueryParams? queryParameters,
|
||||||
|
ApiHeaders? headers,
|
||||||
|
CancelToken? cancelToken,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final url = _buildUrl(endpoint);
|
||||||
|
final response = await _dio.put(
|
||||||
|
url,
|
||||||
|
data: data,
|
||||||
|
queryParameters: queryParameters,
|
||||||
|
options: Options(headers: headers),
|
||||||
|
cancelToken: cancelToken,
|
||||||
|
);
|
||||||
|
return response.data as T;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _handleError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic DELETE request with common error handling
|
||||||
|
Future<T> delete<T>(
|
||||||
|
String endpoint, {
|
||||||
|
QueryParams? queryParameters,
|
||||||
|
ApiHeaders? headers,
|
||||||
|
CancelToken? cancelToken,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final url = _buildUrl(endpoint);
|
||||||
|
final response = await _dio.delete(
|
||||||
|
url,
|
||||||
|
queryParameters: queryParameters,
|
||||||
|
options: Options(headers: headers),
|
||||||
|
cancelToken: cancelToken,
|
||||||
|
);
|
||||||
|
return response.data as T;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _handleError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if response indicates success (useful for status-only operations)
|
||||||
|
bool isSuccessResponse(Response response) {
|
||||||
|
return response.statusCode != null &&
|
||||||
|
response.statusCode! >= 200 &&
|
||||||
|
response.statusCode! < 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts DioException to appropriate ApiException subtype
|
||||||
|
ApiException _handleError(DioException error) {
|
||||||
|
final exception = switch (error.type) {
|
||||||
|
DioExceptionType.connectionTimeout ||
|
||||||
|
DioExceptionType.sendTimeout ||
|
||||||
|
DioExceptionType.receiveTimeout ||
|
||||||
|
DioExceptionType.transformTimeout =>
|
||||||
|
TimeoutException(_getTimeoutMessage(error.type)),
|
||||||
|
DioExceptionType.connectionError => NetworkException(
|
||||||
|
'No internet connection. Please check your network.',
|
||||||
|
),
|
||||||
|
DioExceptionType.badResponse => _handleHttpError(error),
|
||||||
|
DioExceptionType.cancel => ApiException('Request was cancelled'),
|
||||||
|
DioExceptionType.badCertificate =>
|
||||||
|
ApiException('Security certificate error'),
|
||||||
|
DioExceptionType.unknown =>
|
||||||
|
ApiException(error.message ?? 'An unexpected error occurred'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Record server errors and unexpected failures to Crashlytics.
|
||||||
|
// Skip timeouts, network errors (normal offline conditions), 401s, and cancellations.
|
||||||
|
if (exception is ServerException ||
|
||||||
|
error.type == DioExceptionType.unknown ||
|
||||||
|
error.type == DioExceptionType.badCertificate) {
|
||||||
|
try {
|
||||||
|
FirebaseCrashlytics.instance.recordError(
|
||||||
|
exception,
|
||||||
|
error.stackTrace,
|
||||||
|
reason: '${error.requestOptions.method} ${error.requestOptions.uri}',
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// Firebase may not be initialized (e.g., in tests).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets specific timeout message based on timeout type
|
||||||
|
String _getTimeoutMessage(DioExceptionType type) {
|
||||||
|
switch (type) {
|
||||||
|
case DioExceptionType.connectionTimeout:
|
||||||
|
return 'Connection timeout. Please try again.';
|
||||||
|
case DioExceptionType.sendTimeout:
|
||||||
|
return 'Upload timeout. Please try again.';
|
||||||
|
case DioExceptionType.receiveTimeout:
|
||||||
|
return 'Server response timeout. Please try again.';
|
||||||
|
case DioExceptionType.transformTimeout:
|
||||||
|
return 'Response processing timeout. Please try again.';
|
||||||
|
default:
|
||||||
|
return 'Request timeout. Please try again.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles HTTP status code errors with specific exception types
|
||||||
|
ApiException _handleHttpError(DioException error) {
|
||||||
|
final statusCode = error.response?.statusCode;
|
||||||
|
final responseData = error.response?.data;
|
||||||
|
|
||||||
|
// Extract error message from server response
|
||||||
|
String? serverMessage;
|
||||||
|
if (responseData is JsonMap) {
|
||||||
|
serverMessage =
|
||||||
|
responseData['message'] as String? ??
|
||||||
|
responseData['error'] as String? ??
|
||||||
|
responseData['error_description'] as String?;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (statusCode) {
|
||||||
|
case 400:
|
||||||
|
return ApiException(serverMessage ?? 'Invalid request data');
|
||||||
|
case 401:
|
||||||
|
return UnauthorizedException('Please log in to continue');
|
||||||
|
case 403:
|
||||||
|
return ApiException('Access denied. Insufficient permissions');
|
||||||
|
case 404:
|
||||||
|
return NotFoundException(serverMessage ?? 'Resource not found');
|
||||||
|
case 409:
|
||||||
|
return ApiException('Conflict with current state. Please refresh');
|
||||||
|
case 422:
|
||||||
|
return ApiException(serverMessage ?? 'Invalid data provided');
|
||||||
|
case 429:
|
||||||
|
return ApiException(
|
||||||
|
'Rate limit exceeded. Please wait before trying again',
|
||||||
|
);
|
||||||
|
case 500:
|
||||||
|
return ServerException('Server error. Please try again later', 500);
|
||||||
|
case 502:
|
||||||
|
return ServerException('Service temporarily unavailable', 502);
|
||||||
|
case 503:
|
||||||
|
return ServerException(
|
||||||
|
'Service maintenance. Please try again later',
|
||||||
|
503,
|
||||||
|
);
|
||||||
|
case 504:
|
||||||
|
return ServerException('Server timeout. Please try again', 504);
|
||||||
|
default:
|
||||||
|
return ServerException(
|
||||||
|
serverMessage ?? 'HTTP Error $statusCode occurred',
|
||||||
|
statusCode ?? 500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,75 +1,55 @@
|
||||||
import 'dart:convert';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:frosty/apis/base_api_client.dart';
|
||||||
import 'package:frosty/models/badges.dart';
|
import 'package:frosty/models/badges.dart';
|
||||||
import 'package:frosty/models/emotes.dart';
|
import 'package:frosty/models/emotes.dart';
|
||||||
import 'package:http/http.dart';
|
|
||||||
|
|
||||||
/// The BTTV service for making API calls.
|
/// The BTTV service for making API calls.
|
||||||
class BTTVApi {
|
class BTTVApi extends BaseApiClient {
|
||||||
final Client _client;
|
BTTVApi(Dio dio) : super(dio, 'https://api.betterttv.net/3/cached');
|
||||||
|
|
||||||
const BTTVApi(this._client);
|
|
||||||
|
|
||||||
/// Returns a map of global BTTV emotes to their URL.
|
/// Returns a map of global BTTV emotes to their URL.
|
||||||
Future<List<Emote>> getEmotesGlobal() async {
|
Future<List<Emote>> getEmotesGlobal() async {
|
||||||
final url = Uri.parse('https://api.betterttv.net/3/cached/emotes/global');
|
final data = await get<JsonList>('/emotes/global');
|
||||||
|
|
||||||
final response = await _client.get(url);
|
final emotes = data.map((emote) => EmoteBTTV.fromJson(emote)).toList();
|
||||||
if (response.statusCode == 200) {
|
|
||||||
final decoded = jsonDecode(response.body) as List;
|
|
||||||
final emotes = decoded.map((emote) => EmoteBTTV.fromJson(emote)).toList();
|
|
||||||
|
|
||||||
return emotes
|
return emotes
|
||||||
.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvGlobal))
|
.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvGlobal))
|
||||||
.toList();
|
.toList();
|
||||||
} else {
|
|
||||||
return Future.error('Failed to get BTTV global emotes');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a map of a channel's BTTV emotes to their URL.
|
/// Returns a map of a channel's BTTV emotes to their URL.
|
||||||
Future<List<Emote>> getEmotesChannel({required String id}) async {
|
Future<List<Emote>> getEmotesChannel({required String id}) async {
|
||||||
final url =
|
final data = await get<JsonMap>('/users/twitch/$id');
|
||||||
Uri.parse('https://api.betterttv.net/3/cached/users/twitch/$id');
|
|
||||||
|
|
||||||
final response = await _client.get(url);
|
final result = EmoteBTTVChannel.fromJson(data);
|
||||||
if (response.statusCode == 200) {
|
|
||||||
final decoded = jsonDecode(response.body);
|
|
||||||
final result = EmoteBTTVChannel.fromJson(decoded);
|
|
||||||
|
|
||||||
final emoteToUrl = <Emote>[];
|
final emoteToUrl = <Emote>[];
|
||||||
emoteToUrl.addAll(
|
emoteToUrl.addAll(
|
||||||
result.channelEmotes
|
result.channelEmotes.map(
|
||||||
.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvChannel)),
|
(emote) => Emote.fromBTTV(emote, EmoteType.bttvChannel),
|
||||||
);
|
),
|
||||||
emoteToUrl.addAll(
|
);
|
||||||
result.sharedEmotes
|
emoteToUrl.addAll(
|
||||||
.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvShared)),
|
result.sharedEmotes.map(
|
||||||
);
|
(emote) => Emote.fromBTTV(emote, EmoteType.bttvShared),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return emoteToUrl;
|
return emoteToUrl;
|
||||||
} else {
|
|
||||||
return Future.error('Failed to get BTTV channel emotes');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a map of username to their BTTV badge.
|
/// Returns a map of username to their BTTV badge.
|
||||||
Future<Map<String, ChatBadge>> getBadges() async {
|
Future<Map<String, ChatBadge>> getBadges() async {
|
||||||
final url = Uri.parse('https://api.betterttv.net/3/cached/badges');
|
final data = await get<JsonList>('/badges');
|
||||||
|
|
||||||
final response = await _client.get(url);
|
final badgeObjects = data
|
||||||
if (response.statusCode == 200) {
|
.map((badge) => BadgeInfoBTTV.fromJson(badge))
|
||||||
final badges = jsonDecode(response.body) as List;
|
.toList();
|
||||||
|
|
||||||
final badgeObjects =
|
return {
|
||||||
badges.map((badge) => BadgeInfoBTTV.fromJson(badge)).toList();
|
for (final badge in badgeObjects)
|
||||||
|
badge.providerId: ChatBadge.fromBTTV(badge),
|
||||||
return {
|
};
|
||||||
for (final badge in badgeObjects)
|
|
||||||
badge.providerId: ChatBadge.fromBTTV(badge),
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return Future.error('Failed to get BTTV badges');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
71
lib/apis/dio_client.dart
Normal file
71
lib/apis/dio_client.dart
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:frosty/apis/firebase_performance_interceptor.dart';
|
||||||
|
|
||||||
|
/// Centralized Dio client configuration with interceptors and error handling
|
||||||
|
class DioClient {
|
||||||
|
static Dio createClient({List<Interceptor>? additionalInterceptors}) {
|
||||||
|
final dio = Dio();
|
||||||
|
|
||||||
|
// Optimized configuration based on 2024-2025 best practices
|
||||||
|
dio.options = BaseOptions(
|
||||||
|
connectTimeout: const Duration(
|
||||||
|
seconds: 8,
|
||||||
|
), // Balanced for various networks
|
||||||
|
receiveTimeout: const Duration(
|
||||||
|
seconds: 15,
|
||||||
|
), // Allow for larger API responses
|
||||||
|
sendTimeout: const Duration(seconds: 10), // Reasonable for uploads
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Frosty (Flutter Twitch Client)',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Connection': 'keep-alive', // Enable connection reuse
|
||||||
|
},
|
||||||
|
followRedirects: true,
|
||||||
|
maxRedirects: 3, // Reduced for better performance
|
||||||
|
persistentConnection: true, // Enable connection pooling
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simple logging for debug builds
|
||||||
|
if (kDebugMode) {
|
||||||
|
dio.interceptors.add(
|
||||||
|
InterceptorsWrapper(
|
||||||
|
onRequest: (options, handler) {
|
||||||
|
debugPrint('→ ${options.method} ${options.uri}');
|
||||||
|
handler.next(options);
|
||||||
|
},
|
||||||
|
onResponse: (response, handler) {
|
||||||
|
final statusCode = response.statusCode ?? 0;
|
||||||
|
final emoji = statusCode >= 200 && statusCode < 300 ? '✅' : '❌';
|
||||||
|
debugPrint(
|
||||||
|
'$emoji ${response.statusCode} ${response.requestOptions.method} ${response.requestOptions.uri}',
|
||||||
|
);
|
||||||
|
handler.next(response);
|
||||||
|
},
|
||||||
|
onError: (error, handler) {
|
||||||
|
debugPrint(
|
||||||
|
'❌ ${error.type.name} ${error.requestOptions.method} ${error.requestOptions.uri}',
|
||||||
|
);
|
||||||
|
if (error.response != null) {
|
||||||
|
debugPrint(' Status: ${error.response!.statusCode}');
|
||||||
|
}
|
||||||
|
handler.next(error);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trace HTTP requests as Firebase Performance metrics
|
||||||
|
dio.interceptors.add(FirebasePerformanceInterceptor());
|
||||||
|
|
||||||
|
// Add any additional interceptors provided
|
||||||
|
if (additionalInterceptors != null) {
|
||||||
|
for (final interceptor in additionalInterceptors) {
|
||||||
|
dio.interceptors.add(interceptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,90 +1,69 @@
|
||||||
import 'dart:convert';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:frosty/apis/base_api_client.dart';
|
||||||
import 'package:frosty/models/badges.dart';
|
import 'package:frosty/models/badges.dart';
|
||||||
import 'package:frosty/models/emotes.dart';
|
import 'package:frosty/models/emotes.dart';
|
||||||
import 'package:http/http.dart';
|
|
||||||
|
|
||||||
/// The FFZ service for making API calls.
|
/// The FFZ service for making API calls.
|
||||||
class FFZApi {
|
class FFZApi extends BaseApiClient {
|
||||||
final Client _client;
|
FFZApi(Dio dio) : super(dio, 'https://api.frankerfacez.com/v1');
|
||||||
|
|
||||||
const FFZApi(this._client);
|
|
||||||
|
|
||||||
/// Returns a map of global FFZ emotes to their URL.
|
/// Returns a map of global FFZ emotes to their URL.
|
||||||
Future<List<Emote>> getEmotesGlobal() async {
|
Future<List<Emote>> getEmotesGlobal() async {
|
||||||
final url = Uri.parse('https://api.frankerfacez.com/v1/set/global');
|
final data = await get<JsonMap>('/set/global');
|
||||||
|
|
||||||
final response = await _client.get(url);
|
final defaultSets = data['default_sets'] as JsonList;
|
||||||
if (response.statusCode == 200) {
|
|
||||||
final decoded = jsonDecode(response.body);
|
|
||||||
final defaultSets = decoded['default_sets'] as List;
|
|
||||||
|
|
||||||
final emotes = <EmoteFFZ>[];
|
final emotes = <EmoteFFZ>[];
|
||||||
for (final setId in defaultSets) {
|
for (final setId in defaultSets) {
|
||||||
final emoticons =
|
final emoticons = data['sets'][setId.toString()]['emoticons'] as JsonList;
|
||||||
decoded['sets'][setId.toString()]['emoticons'] as List;
|
emotes.addAll(emoticons.map((emote) => EmoteFFZ.fromJson(emote)));
|
||||||
emotes.addAll(emoticons.map((emote) => EmoteFFZ.fromJson(emote)));
|
|
||||||
}
|
|
||||||
|
|
||||||
return emotes
|
|
||||||
.map((emote) => Emote.fromFFZ(emote, EmoteType.ffzGlobal))
|
|
||||||
.toList();
|
|
||||||
} else {
|
|
||||||
return Future.error('Failed to get FFZ global emotes');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return emotes
|
||||||
|
.map((emote) => Emote.fromFFZ(emote, EmoteType.ffzGlobal))
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a channel's FFZ room info including custom badges and emote used.
|
/// Returns a channel's FFZ room info including custom badges and emote used.
|
||||||
Future<(RoomFFZ, List<Emote>)> getRoomInfo({required String id}) async {
|
Future<(RoomFFZ, List<Emote>)> getRoomInfo({required String id}) async {
|
||||||
final url = Uri.parse('https://api.frankerfacez.com/v1/room/id/$id');
|
final data = await get<JsonMap>('/room/id/$id');
|
||||||
|
|
||||||
final response = await _client.get(url);
|
final roomInfo = RoomFFZ.fromJson(data['room']);
|
||||||
if (response.statusCode == 200) {
|
final emoticons =
|
||||||
final decoded = jsonDecode(response.body);
|
data['sets'][roomInfo.set.toString()]['emoticons'] as JsonList;
|
||||||
final roomInfo = RoomFFZ.fromJson(decoded['room']);
|
|
||||||
final emoticons =
|
|
||||||
decoded['sets'][roomInfo.set.toString()]['emoticons'] as List;
|
|
||||||
|
|
||||||
final emotes = emoticons.map((emote) => EmoteFFZ.fromJson(emote));
|
final emotes = emoticons.map((emote) => EmoteFFZ.fromJson(emote));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
roomInfo,
|
roomInfo,
|
||||||
emotes
|
emotes
|
||||||
.map((emote) => Emote.fromFFZ(emote, EmoteType.ffzChannel))
|
.map((emote) => Emote.fromFFZ(emote, EmoteType.ffzChannel))
|
||||||
.toList()
|
.toList(),
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
return Future.error('Failed to get FFZ room info');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a map of badges user IDs to a list of their FFZ badges.
|
/// Returns a map of user IDs to a list of their FFZ badges.
|
||||||
Future<Map<String, List<ChatBadge>>> getBadges() async {
|
Future<Map<String, List<ChatBadge>>> getBadges() async {
|
||||||
final url = Uri.parse('https://api.frankerfacez.com/v1/badges/ids');
|
final data = await get<JsonMap>('/badges/ids');
|
||||||
|
|
||||||
final response = await _client.get(url);
|
final badges = data['badges'] as JsonList;
|
||||||
if (response.statusCode == 200) {
|
final badgeObjects = badges
|
||||||
final decoded = jsonDecode(response.body);
|
.map((badge) => BadgeInfoFFZ.fromJson(badge))
|
||||||
final badges = decoded['badges'] as List;
|
.toList();
|
||||||
final badgeObjects =
|
|
||||||
badges.map((badge) => BadgeInfoFFZ.fromJson(badge)).toList();
|
|
||||||
|
|
||||||
final result = <String, List<ChatBadge>>{};
|
final result = <String, List<ChatBadge>>{};
|
||||||
for (final badge in badgeObjects.reversed) {
|
for (final badge in badgeObjects.reversed) {
|
||||||
for (final userId in decoded['users'][badge.id.toString()]) {
|
for (final userId in data['users'][badge.id.toString()]) {
|
||||||
final entry = result[userId.toString()];
|
final entry = result[userId.toString()];
|
||||||
final normalBadge = ChatBadge.fromFFZ(badge);
|
final normalBadge = ChatBadge.fromFFZ(badge);
|
||||||
if (entry == null) {
|
if (entry == null) {
|
||||||
result[userId.toString()] = [normalBadge];
|
result[userId.toString()] = [normalBadge];
|
||||||
} else {
|
} else {
|
||||||
entry.add(normalBadge);
|
entry.add(normalBadge);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
|
||||||
} else {
|
|
||||||
return Future.error('Failed to get FFZ badges');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
46
lib/apis/firebase_performance_interceptor.dart
Normal file
46
lib/apis/firebase_performance_interceptor.dart
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:firebase_performance/firebase_performance.dart';
|
||||||
|
|
||||||
|
/// Dio interceptor that traces HTTP requests as Firebase Performance metrics.
|
||||||
|
class FirebasePerformanceInterceptor extends Interceptor {
|
||||||
|
static const _metricKey = 'firebase_performance_metric';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||||
|
final metric = FirebasePerformance.instance.newHttpMetric(
|
||||||
|
options.uri.toString(),
|
||||||
|
_getHttpMethod(options.method),
|
||||||
|
);
|
||||||
|
metric.start();
|
||||||
|
options.extra[_metricKey] = metric;
|
||||||
|
handler.next(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||||
|
_stopMetric(response.requestOptions, response.statusCode);
|
||||||
|
handler.next(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||||
|
_stopMetric(err.requestOptions, err.response?.statusCode);
|
||||||
|
handler.next(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopMetric(RequestOptions options, int? statusCode) {
|
||||||
|
final metric = options.extra.remove(_metricKey) as HttpMetric?;
|
||||||
|
if (metric == null) return;
|
||||||
|
if (statusCode != null) metric.httpResponseCode = statusCode;
|
||||||
|
metric.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
static HttpMethod _getHttpMethod(String method) => switch (method.toUpperCase()) {
|
||||||
|
'GET' => HttpMethod.Get,
|
||||||
|
'POST' => HttpMethod.Post,
|
||||||
|
'PUT' => HttpMethod.Put,
|
||||||
|
'DELETE' => HttpMethod.Delete,
|
||||||
|
'PATCH' => HttpMethod.Patch,
|
||||||
|
_ => HttpMethod.Get,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,52 +1,39 @@
|
||||||
import 'dart:convert';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:frosty/apis/base_api_client.dart';
|
||||||
import 'package:frosty/models/emotes.dart';
|
import 'package:frosty/models/emotes.dart';
|
||||||
import 'package:http/http.dart';
|
|
||||||
|
|
||||||
/// The 7TV service for making API calls.
|
/// The 7TV service for making API calls.
|
||||||
class SevenTVApi {
|
class SevenTVApi extends BaseApiClient {
|
||||||
final Client _client;
|
SevenTVApi(Dio dio) : super(dio, 'https://7tv.io/v3');
|
||||||
|
|
||||||
const SevenTVApi(this._client);
|
|
||||||
|
|
||||||
/// Returns a map of global 7TV emotes to their URL.
|
/// Returns a map of global 7TV emotes to their URL.
|
||||||
Future<List<Emote>> getEmotesGlobal() async {
|
Future<List<Emote>> getEmotesGlobal() async {
|
||||||
final url = Uri.parse('https://7tv.io/v3/emote-sets/global');
|
final data = await get<JsonMap>('/emote-sets/global');
|
||||||
|
|
||||||
final response = await _client.get(url);
|
final decoded = data['emotes'] as JsonList;
|
||||||
if (response.statusCode == 200) {
|
final emotes = decoded.map((emote) => Emote7TV.fromJson(emote));
|
||||||
final decoded = jsonDecode(response.body)['emotes'] as List;
|
|
||||||
final emotes = decoded.map((emote) => Emote7TV.fromJson(emote));
|
|
||||||
|
|
||||||
return emotes
|
return emotes
|
||||||
.map((emote) => Emote.from7TV(emote, EmoteType.sevenTVGlobal))
|
.map((emote) => Emote.from7TV(emote, EmoteType.sevenTVGlobal))
|
||||||
.toList();
|
.toList();
|
||||||
} else {
|
|
||||||
return Future.error('Failed to get 7TV global emotes');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a tuple containing the emote set ID and a map of a channel's 7TV
|
/// Returns a tuple containing the emote set ID and a map of a channel's 7TV
|
||||||
/// emotes to their URL.
|
/// emotes to their URL.
|
||||||
Future<(String, List<Emote>)> getEmotesChannel({required String id}) async {
|
Future<(String, List<Emote>)> getEmotesChannel({required String id}) async {
|
||||||
final url = Uri.parse('https://7tv.io/v3/users/twitch/$id');
|
final data = await get<JsonMap>('/users/twitch/$id');
|
||||||
|
|
||||||
final response = await _client.get(url);
|
final emoteSetId = data['emote_set']['id'] as String;
|
||||||
if (response.statusCode == 200) {
|
final emotes = (data['emote_set']['emotes'] as JsonList).map(
|
||||||
final decoded = jsonDecode(response.body);
|
(emote) => Emote7TV.fromJson(emote),
|
||||||
final emoteSetId = decoded['emote_set']['id'] as String;
|
);
|
||||||
final emotes = (decoded['emote_set']['emotes'] as List)
|
|
||||||
.map((emote) => Emote7TV.fromJson(emote));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
emoteSetId,
|
emoteSetId,
|
||||||
emotes
|
emotes
|
||||||
.map((emote) => Emote.from7TV(emote, EmoteType.sevenTVChannel))
|
.map((emote) => Emote.from7TV(emote, EmoteType.sevenTVChannel))
|
||||||
.where((emote) => emote.url.isNotEmpty)
|
.where((emote) => emote.url.isNotEmpty)
|
||||||
.toList()
|
.toList(),
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
return Future.error('Failed to get 7TV channel emotes');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
163
lib/apis/twitch_ad_solutions.dart
Normal file
163
lib/apis/twitch_ad_solutions.dart
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||||
|
import 'package:frosty/models/playback_access_token.dart';
|
||||||
|
|
||||||
|
/// Dart-side port of the [TwitchAdSolutions vaft](https://github.com/pixeltris/TwitchAdSolutions)
|
||||||
|
/// strategy for Frosty's native HLS player.
|
||||||
|
///
|
||||||
|
/// Browser vaft hooks `Worker`/`fetch` to rewrite playlists. Here we instead:
|
||||||
|
/// 1. Request playback tokens with a non-`site` [forceAccessTokenPlayerType]
|
||||||
|
/// (vaft's `ForceAccessTokenPlayerType`).
|
||||||
|
/// 2. When stitched ads still appear, try [backupPlayerTypes] until a playlist
|
||||||
|
/// without the ad signifier is found, then hand that Usher URL to the player.
|
||||||
|
class TwitchAdSolutions {
|
||||||
|
TwitchAdSolutions._();
|
||||||
|
|
||||||
|
/// Matches vaft `AdSignifier` — Twitch marks ad pods with this in `#EXT-X-DATERANGE`.
|
||||||
|
static const adSignifier = 'stitched';
|
||||||
|
|
||||||
|
/// vaft `ForceAccessTokenPlayerType` — used for the primary stream when
|
||||||
|
/// ad blocking is enabled.
|
||||||
|
static const forceAccessTokenPlayerType = 'popout';
|
||||||
|
|
||||||
|
/// vaft `BackupPlayerTypes` order when the primary playlist still has ads.
|
||||||
|
static const backupPlayerTypes = <String>[
|
||||||
|
'embed',
|
||||||
|
'popout',
|
||||||
|
'autoplay',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// vaft `FallbackPlayerType` when every backup still carries ads.
|
||||||
|
static const fallbackPlayerType = 'embed';
|
||||||
|
|
||||||
|
/// GQL `platform` for a given player type (vaft uses `android` for autoplay).
|
||||||
|
static String platformForPlayerType(String playerType) =>
|
||||||
|
playerType == 'autoplay' ? 'android' : 'web';
|
||||||
|
|
||||||
|
static bool playlistContainsAds(String playlistText) =>
|
||||||
|
playlistText.contains(adSignifier);
|
||||||
|
|
||||||
|
/// True when [text] looks like a midroll (vaft midroll banner path).
|
||||||
|
static bool playlistIsMidroll(String playlistText) =>
|
||||||
|
playlistText.contains('"MIDROLL"') ||
|
||||||
|
playlistText.contains('"midroll"') ||
|
||||||
|
playlistText.contains("MIDROLL");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves Usher HLS URLs using TwitchAdSolutions player-type strategy.
|
||||||
|
class TwitchAdFreePlayback {
|
||||||
|
TwitchAdFreePlayback(this._gql);
|
||||||
|
|
||||||
|
final TwitchGqlApi _gql;
|
||||||
|
|
||||||
|
/// Primary URL when adblock is on: force [TwitchAdSolutions.forceAccessTokenPlayerType].
|
||||||
|
Future<({String url, PlaybackAccessToken token, String playerType})>
|
||||||
|
resolvePrimary({
|
||||||
|
required String login,
|
||||||
|
String? authToken,
|
||||||
|
}) {
|
||||||
|
return _resolveForPlayerType(
|
||||||
|
login: login,
|
||||||
|
authToken: authToken,
|
||||||
|
playerType: TwitchAdSolutions.forceAccessTokenPlayerType,
|
||||||
|
requireAdFree: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walks [TwitchAdSolutions.backupPlayerTypes] for a playlist without ads.
|
||||||
|
/// Falls back to [TwitchAdSolutions.fallbackPlayerType] even if ads remain.
|
||||||
|
Future<({String url, PlaybackAccessToken token, String playerType})>
|
||||||
|
resolveBackup({
|
||||||
|
required String login,
|
||||||
|
String? authToken,
|
||||||
|
}) async {
|
||||||
|
for (final playerType in TwitchAdSolutions.backupPlayerTypes) {
|
||||||
|
try {
|
||||||
|
final resolved = await _resolveForPlayerType(
|
||||||
|
login: login,
|
||||||
|
authToken: authToken,
|
||||||
|
playerType: playerType,
|
||||||
|
requireAdFree: true,
|
||||||
|
);
|
||||||
|
debugPrint(
|
||||||
|
'TwitchAdFreePlayback: backup OK playerType=$playerType',
|
||||||
|
);
|
||||||
|
return resolved;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(
|
||||||
|
'TwitchAdFreePlayback: backup playerType=$playerType failed: $e',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'TwitchAdFreePlayback: all backups had ads; using fallback '
|
||||||
|
'${TwitchAdSolutions.fallbackPlayerType}',
|
||||||
|
);
|
||||||
|
return _resolveForPlayerType(
|
||||||
|
login: login,
|
||||||
|
authToken: authToken,
|
||||||
|
playerType: TwitchAdSolutions.fallbackPlayerType,
|
||||||
|
requireAdFree: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<({String url, PlaybackAccessToken token, String playerType})>
|
||||||
|
_resolveForPlayerType({
|
||||||
|
required String login,
|
||||||
|
String? authToken,
|
||||||
|
required String playerType,
|
||||||
|
required bool requireAdFree,
|
||||||
|
}) async {
|
||||||
|
final token = await _gql.getPlaybackAccessToken(
|
||||||
|
login: login,
|
||||||
|
authToken: authToken,
|
||||||
|
playerType: playerType,
|
||||||
|
platform: TwitchAdSolutions.platformForPlayerType(playerType),
|
||||||
|
);
|
||||||
|
final url = _gql.buildHlsUrl(login: login, token: token);
|
||||||
|
|
||||||
|
if (requireAdFree) {
|
||||||
|
final hasAds = await _multivariantHasAds(url);
|
||||||
|
if (hasAds) {
|
||||||
|
throw StateError('Playlist for playerType=$playerType still has ads');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (url: url, token: token, playerType: playerType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches the multivariant playlist and samples the first media variant for
|
||||||
|
/// the ad signifier (same idea as vaft probing backup encodings).
|
||||||
|
Future<bool> _multivariantHasAds(String hlsUrl) async {
|
||||||
|
final master = await _gql.fetchPlaylistText(hlsUrl);
|
||||||
|
final mediaUrl = firstMediaPlaylistUrl(master, baseUrl: hlsUrl);
|
||||||
|
if (mediaUrl == null) {
|
||||||
|
// Can't verify — treat as ad-free so we don't reject a usable stream.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final media = await _gql.fetchPlaylistText(mediaUrl);
|
||||||
|
return TwitchAdSolutions.playlistContainsAds(media);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First `.m3u8` URI in a multivariant playlist, resolved against [baseUrl].
|
||||||
|
@visibleForTesting
|
||||||
|
static String? firstMediaPlaylistUrl(
|
||||||
|
String masterPlaylist, {
|
||||||
|
required String baseUrl,
|
||||||
|
}) {
|
||||||
|
final lines = masterPlaylist.replaceAll('\r', '').split('\n');
|
||||||
|
for (final raw in lines) {
|
||||||
|
final line = raw.trim();
|
||||||
|
if (line.isEmpty || line.startsWith('#')) continue;
|
||||||
|
if (!line.contains('.m3u8')) continue;
|
||||||
|
return Uri.parse(baseUrl).resolve(line).toString();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tiny blank MP4 (same payload vaft serves for stripped ad segments). Exposed
|
||||||
|
/// for tests / future local playlist rewriting; not required for URL-swap mode.
|
||||||
|
const twitchAdSolutionsBlankSegmentDataUri =
|
||||||
|
'data:video/mp4;base64,AAAAKGZ0eXBtcDQyAAAAAWlzb21tcDQyZGFzaGF2YzFpc282aGxzZgAABEltb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAYagAAAAAAABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAABqHRyYWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAURtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAALuAAAAAAFXEAAAAAAAtaGRscgAAAAAAAAAAc291bgAAAAAAAAAAAAAAAFNvdW5kSGFuZGxlcgAAAADvbWluZgAAABBzbWhkAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAACzc3RibAAAAGdzdHNkAAAAAAAAAAEAAABXbXA0YQAAAAAAAAABAAAAAAAAAAAAAgAQAAAAALuAAAAAAAAzZXNkcwAAAAADgICAIgABAASAgIAUQBUAAAAAAAAAAAAAAAWAgIACEZAGgICAAQIAAAAQc3R0cwAAAAAAAAAAAAAAEHN0c2MAAAAAAAAAAAAAABRzdHN6AAAAAAAAAAAAAAAAAAAAEHN0Y28AAAAAAAAAAAAAAeV0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAoAAAAFoAAAAAAGBbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAA9CQAAAAABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABLG1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAOxzdGJsAAAAoHN0c2QAAAAAAAAAAQAAAJBhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAoABaABIAAAASAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAOmF2Y0MBTUAe/+EAI2dNQB6WUoFAX/LgLUBAQFAAAD6AAA6mDgAAHoQAA9CW7y4KAQAEaOuPIAAAABBzdHRzAAAAAAAAAAAAAAAQc3RzYwAAAAAAAAAAAAAAFHN0c3oAAAAAAAAAAAAAAAAAAAAQc3RjbwAAAAAAAAAAAAAASG12ZXgAAAAgdHJleAAAAAAAAAABAAAAAQAAAC4AAAAAAoAAAAAAACB0cmV4AAAAAAAAAAIAAAABAACCNQAAAAACQAAA';
|
||||||
File diff suppressed because it is too large
Load diff
31
lib/apis/twitch_auth_interceptor.dart
Normal file
31
lib/apis/twitch_auth_interceptor.dart
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
|
|
||||||
|
/// Dio interceptor that automatically adds Twitch authentication headers
|
||||||
|
/// to requests targeting Twitch API endpoints.
|
||||||
|
class TwitchAuthInterceptor extends Interceptor {
|
||||||
|
final AuthStore _authStore;
|
||||||
|
|
||||||
|
const TwitchAuthInterceptor(this._authStore);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||||
|
// Check if this is a Twitch API request that needs authentication
|
||||||
|
if (_shouldAddTwitchHeaders(options.uri)) {
|
||||||
|
// Add Twitch auth headers automatically
|
||||||
|
final twitchHeaders = _authStore.headersTwitch;
|
||||||
|
options.headers.addAll(twitchHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.next(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determines if the request URL is for a Twitch API endpoint that requires authentication
|
||||||
|
bool _shouldAddTwitchHeaders(Uri uri) {
|
||||||
|
final url = uri.toString();
|
||||||
|
|
||||||
|
// Add headers for Twitch Helix API and OAuth endpoints
|
||||||
|
return url.startsWith('https://api.twitch.tv/helix') ||
|
||||||
|
url.startsWith('https://id.twitch.tv/oauth2');
|
||||||
|
}
|
||||||
|
}
|
||||||
559
lib/apis/twitch_gql_api.dart
Normal file
559
lib/apis/twitch_gql_api.dart
Normal file
|
|
@ -0,0 +1,559 @@
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:frosty/apis/base_api_client.dart';
|
||||||
|
import 'package:frosty/apis/twitch_integrity.dart';
|
||||||
|
import 'package:frosty/constants.dart';
|
||||||
|
import 'package:frosty/models/playback_access_token.dart';
|
||||||
|
import 'package:frosty/models/stream.dart';
|
||||||
|
|
||||||
|
/// Viewer sort for GQL directory / tag stream lists.
|
||||||
|
enum StreamViewerSort {
|
||||||
|
viewersDesc,
|
||||||
|
viewersAsc;
|
||||||
|
|
||||||
|
String get gqlValue => switch (this) {
|
||||||
|
StreamViewerSort.viewersDesc => 'VIEWER_COUNT',
|
||||||
|
StreamViewerSort.viewersAsc => 'VIEWER_COUNT_ASC',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Twitch GQL API client for playback tokens and directory queries
|
||||||
|
/// (freeform tags + sorted streams) that Helix does not expose.
|
||||||
|
///
|
||||||
|
/// Uses the Twitch web client ID (not the app's registered client ID)
|
||||||
|
/// since the GQL endpoint requires it.
|
||||||
|
class TwitchGqlApi extends BaseApiClient {
|
||||||
|
TwitchGqlApi(Dio dio) : super(dio, 'https://gql.twitch.tv');
|
||||||
|
|
||||||
|
static const _gqlHeaders = <String, String>{
|
||||||
|
'Client-ID': twitchGqlClientId,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<JsonMap> _gql(
|
||||||
|
Map<String, Object?> body, {
|
||||||
|
Map<String, String>? extraHeaders,
|
||||||
|
}) async {
|
||||||
|
final response = await post<JsonMap>(
|
||||||
|
'/gql',
|
||||||
|
data: body,
|
||||||
|
headers: {..._gqlHeaders, ...?extraHeaders},
|
||||||
|
);
|
||||||
|
final errors = response['errors'];
|
||||||
|
if (errors is List && errors.isNotEmpty) {
|
||||||
|
final message = (errors.first as JsonMap)['message'] as String? ??
|
||||||
|
'Twitch GQL error';
|
||||||
|
if (message.toLowerCase().contains('integrity')) {
|
||||||
|
throw IntegrityCheckException(message);
|
||||||
|
}
|
||||||
|
final data = response['data'];
|
||||||
|
final dataEmpty = data == null ||
|
||||||
|
(data is Map &&
|
||||||
|
data.values.every((value) => value == null));
|
||||||
|
if (dataEmpty) {
|
||||||
|
throw ApiException(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optional web-session headers for directory pagination (page-2+).
|
||||||
|
Map<String, String> _directoryHeaders({
|
||||||
|
String? authToken,
|
||||||
|
String? integrityToken,
|
||||||
|
String? deviceId,
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
'User-Agent': playbackUserAgent,
|
||||||
|
if (authToken != null && authToken.isNotEmpty)
|
||||||
|
'Authorization': 'OAuth $authToken',
|
||||||
|
if (deviceId != null && deviceId.isNotEmpty) 'X-Device-Id': deviceId,
|
||||||
|
if (integrityToken != null && integrityToken.isNotEmpty)
|
||||||
|
'Client-Integrity': integrityToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Browser-consistent User-Agent for playback-related requests. Twitch has
|
||||||
|
/// rejected access-token requests with non-browser UAs before
|
||||||
|
/// (streamlink#6574); sending one consistent with the web client ID and
|
||||||
|
/// web session token avoids the obvious fingerprint mismatch.
|
||||||
|
static const playbackUserAgent =
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
|
||||||
|
'(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
|
||||||
|
|
||||||
|
/// Headers for the HLS playlist/segment requests issued by the native
|
||||||
|
/// player, mirroring what the web player sends to Usher/weaver.
|
||||||
|
static const playbackHttpHeaders = <String, String>{
|
||||||
|
'User-Agent': playbackUserAgent,
|
||||||
|
'Referer': 'https://player.twitch.tv',
|
||||||
|
'Origin': 'https://player.twitch.tv',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Fetches a playback access token for the given channel [login].
|
||||||
|
///
|
||||||
|
/// When [authToken] is provided, the request is authenticated which may
|
||||||
|
/// grant ad-free playback for subscribers and Turbo users.
|
||||||
|
///
|
||||||
|
/// [playerType] / [platform] mirror the web player's GQL variables. Ad
|
||||||
|
/// blocking (TwitchAdSolutions / vaft) uses non-`site` player types such as
|
||||||
|
/// `popout` / `embed` / `autoplay`.
|
||||||
|
///
|
||||||
|
/// Uses the web client's persisted GQL query (smaller payload, blends in
|
||||||
|
/// with regular web traffic); falls back to the inline query template if
|
||||||
|
/// Twitch ever rotates the persisted-query hash.
|
||||||
|
Future<PlaybackAccessToken> getPlaybackAccessToken({
|
||||||
|
required String login,
|
||||||
|
String? authToken,
|
||||||
|
String playerType = 'site',
|
||||||
|
String platform = 'web',
|
||||||
|
}) async {
|
||||||
|
final headers = <String, String>{
|
||||||
|
'Client-ID': twitchGqlClientId,
|
||||||
|
'User-Agent': playbackUserAgent,
|
||||||
|
if (authToken != null) 'Authorization': 'OAuth $authToken',
|
||||||
|
};
|
||||||
|
|
||||||
|
final variables = {
|
||||||
|
'isLive': true,
|
||||||
|
'login': login,
|
||||||
|
'isVod': false,
|
||||||
|
'vodID': '',
|
||||||
|
'playerType': playerType,
|
||||||
|
'platform': platform,
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<JsonMap> query(Map<String, Object> body) =>
|
||||||
|
post<JsonMap>('/gql', data: body, headers: headers);
|
||||||
|
|
||||||
|
final response = await query({
|
||||||
|
'operationName': 'PlaybackAccessToken',
|
||||||
|
'extensions': {
|
||||||
|
'persistedQuery': {
|
||||||
|
'version': 1,
|
||||||
|
// Stable for years; used by the web player and streamlink.
|
||||||
|
'sha256Hash':
|
||||||
|
'ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'variables': variables,
|
||||||
|
});
|
||||||
|
|
||||||
|
// PersistedQueryNotFound (or similar) comes back as a 200 with an
|
||||||
|
// errors array — retry once with the inline template before failing.
|
||||||
|
if (response['errors'] != null && response['data'] == null) {
|
||||||
|
debugPrint(
|
||||||
|
'TwitchGqlApi: persisted query failed (${response['errors']}), '
|
||||||
|
'falling back to inline template',
|
||||||
|
);
|
||||||
|
return PlaybackAccessToken.fromGqlResponse(
|
||||||
|
await query({
|
||||||
|
'operationName': 'PlaybackAccessToken_Template',
|
||||||
|
'query':
|
||||||
|
'query PlaybackAccessToken_Template(\$login: String!, \$isLive: Boolean!, \$vodID: ID!, \$isVod: Boolean!, \$playerType: String!) { streamPlaybackAccessToken(channelName: \$login, params: {platform: "web", playerBackend: "mediaplayer", playerType: \$playerType}) @include(if: \$isLive) { value signature __typename } videoPlaybackAccessToken(id: \$vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: \$playerType}) @include(if: \$isVod) { value signature __typename } }',
|
||||||
|
'variables': variables,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return PlaybackAccessToken.fromGqlResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches raw playlist text (multivariant or media) with playback headers.
|
||||||
|
Future<String> fetchPlaylistText(String url) async {
|
||||||
|
return get<String>(url, headers: playbackHttpHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the HLS stream URL for the given channel [login] and [token].
|
||||||
|
String buildHlsUrl({
|
||||||
|
required String login,
|
||||||
|
required PlaybackAccessToken token,
|
||||||
|
}) {
|
||||||
|
final random = Random().nextInt(999999);
|
||||||
|
final encodedToken = Uri.encodeComponent(token.value);
|
||||||
|
|
||||||
|
// Usher v2 — what the web player and streamlink use today. Same query
|
||||||
|
// params and token; the multivariant playlist is standards-compliant
|
||||||
|
// (EXT-X-SESSION-DATA + STREAM-INF only, no EXT-X-MEDIA/TWITCH-INFO).
|
||||||
|
return 'https://usher.ttvnw.net/api/v2/channel/hls/$login.m3u8'
|
||||||
|
'?sig=${token.signature}'
|
||||||
|
'&token=$encodedToken'
|
||||||
|
'&allow_source=true'
|
||||||
|
'&allow_audio_only=true'
|
||||||
|
'&fast_bread=true'
|
||||||
|
'&supported_codecs=av1,h265,h264'
|
||||||
|
'&reassignments_supported=true'
|
||||||
|
'&platform=web'
|
||||||
|
'&player_backend=mediaplayer'
|
||||||
|
'&play_session_id=${_randomSessionId()}'
|
||||||
|
'&playlist_include_framerate=true'
|
||||||
|
'&p=$random';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 32-char hex play-session id, matching the web client and streamlink. Sent
|
||||||
|
/// to Usher to identify the playback session; Usher tolerates its absence
|
||||||
|
/// today but has historically tightened required params.
|
||||||
|
String _randomSessionId() {
|
||||||
|
final random = Random();
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
for (var i = 0; i < 32; i++) {
|
||||||
|
buffer.write(random.nextInt(16).toRadixString(16));
|
||||||
|
}
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Freeform tag name suggestions for search-as-you-type.
|
||||||
|
Future<List<String>> searchFreeformTags({
|
||||||
|
required String query,
|
||||||
|
int first = 20,
|
||||||
|
}) async {
|
||||||
|
if (query.trim().isEmpty) return const [];
|
||||||
|
|
||||||
|
final response = await _gql({
|
||||||
|
'operationName': 'SearchFreeformTags',
|
||||||
|
'query':
|
||||||
|
r'query SearchFreeformTags($query: String!, $first: Int) {'
|
||||||
|
r' searchFreeformTags(userQuery: $query, first: $first) {'
|
||||||
|
r' edges { node { tagName } } } }',
|
||||||
|
'variables': {'query': query, 'first': first},
|
||||||
|
});
|
||||||
|
|
||||||
|
final edges =
|
||||||
|
(((response['data'] as JsonMap?)?['searchFreeformTags']
|
||||||
|
as JsonMap?)?['edges']
|
||||||
|
as List?) ??
|
||||||
|
const [];
|
||||||
|
|
||||||
|
return edges
|
||||||
|
.map((edge) {
|
||||||
|
final node = (edge as JsonMap)['node'] as JsonMap?;
|
||||||
|
return node?['tagName'] as String?;
|
||||||
|
})
|
||||||
|
.whereType<String>()
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live streams matching optional [freeformTags] / [languages], sorted by viewers.
|
||||||
|
///
|
||||||
|
/// Twitch caps `first` at 30 for this connection. Cursor pagination needs a
|
||||||
|
/// Client-Integrity token (+ matching [deviceId] / web [authToken]); without
|
||||||
|
/// them page-2 throws [IntegrityCheckException].
|
||||||
|
Future<StreamsTwitch> getStreams({
|
||||||
|
List<String>? freeformTags,
|
||||||
|
List<String>? languages,
|
||||||
|
StreamViewerSort sort = StreamViewerSort.viewersDesc,
|
||||||
|
String? cursor,
|
||||||
|
int first = 30,
|
||||||
|
String? authToken,
|
||||||
|
String? integrityToken,
|
||||||
|
String? deviceId,
|
||||||
|
}) async {
|
||||||
|
final cappedFirst = first.clamp(1, 30);
|
||||||
|
final gqlLangs = languages == null || languages.isEmpty
|
||||||
|
? null
|
||||||
|
: languages
|
||||||
|
.map((l) => l.trim().toUpperCase())
|
||||||
|
.where((l) => l.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final response = await _gql(
|
||||||
|
{
|
||||||
|
'operationName': 'FrostyTopStreams',
|
||||||
|
'query':
|
||||||
|
r'query FrostyTopStreams($first: Int, $after: Cursor, $tags: [String!], $sort: StreamSort, $langs: [Language!]) {'
|
||||||
|
r' streams(first: $first, after: $after, options: { sort: $sort, freeformTags: $tags, languages: $langs }) {'
|
||||||
|
r' edges { cursor node {'
|
||||||
|
r' id title viewersCount createdAt language'
|
||||||
|
r' game { id name }'
|
||||||
|
r' broadcaster { id login displayName }'
|
||||||
|
r' } } pageInfo { hasNextPage } } }',
|
||||||
|
'variables': {
|
||||||
|
'first': cappedFirst,
|
||||||
|
'after': cursor,
|
||||||
|
'tags': freeformTags,
|
||||||
|
'sort': sort.gqlValue,
|
||||||
|
'langs': gqlLangs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraHeaders: _directoryHeaders(
|
||||||
|
authToken: authToken,
|
||||||
|
integrityToken: integrityToken,
|
||||||
|
deviceId: deviceId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final connection =
|
||||||
|
(response['data'] as JsonMap?)?['streams'] as JsonMap? ?? const {};
|
||||||
|
return _streamsFromConnection(connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live streams under a category/game, with viewer sort and optional languages.
|
||||||
|
Future<StreamsTwitch> getGameStreams({
|
||||||
|
required String gameId,
|
||||||
|
List<String>? languages,
|
||||||
|
StreamViewerSort sort = StreamViewerSort.viewersDesc,
|
||||||
|
String? cursor,
|
||||||
|
int first = 30,
|
||||||
|
String? authToken,
|
||||||
|
String? integrityToken,
|
||||||
|
String? deviceId,
|
||||||
|
}) async {
|
||||||
|
final cappedFirst = first.clamp(1, 30);
|
||||||
|
final gqlLangs = languages == null || languages.isEmpty
|
||||||
|
? null
|
||||||
|
: languages
|
||||||
|
.map((l) => l.trim().toUpperCase())
|
||||||
|
.where((l) => l.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final response = await _gql(
|
||||||
|
{
|
||||||
|
'operationName': 'FrostyGameStreams',
|
||||||
|
'query':
|
||||||
|
r'query FrostyGameStreams($id: ID!, $first: Int, $after: Cursor, $sort: StreamSort, $langs: [Language!]) {'
|
||||||
|
r' game(id: $id) { streams(first: $first, after: $after, options: { sort: $sort, languages: $langs }) {'
|
||||||
|
r' edges { cursor node {'
|
||||||
|
r' id title viewersCount createdAt language'
|
||||||
|
r' game { id name }'
|
||||||
|
r' broadcaster { id login displayName }'
|
||||||
|
r' } } pageInfo { hasNextPage } } } }',
|
||||||
|
'variables': {
|
||||||
|
'id': gameId,
|
||||||
|
'first': cappedFirst,
|
||||||
|
'after': cursor,
|
||||||
|
'sort': sort.gqlValue,
|
||||||
|
'langs': gqlLangs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraHeaders: _directoryHeaders(
|
||||||
|
authToken: authToken,
|
||||||
|
integrityToken: integrityToken,
|
||||||
|
deviceId: deviceId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final game = (response['data'] as JsonMap?)?['game'] as JsonMap?;
|
||||||
|
if (game == null) {
|
||||||
|
return const StreamsTwitch([], {});
|
||||||
|
}
|
||||||
|
return _streamsFromConnection(game['streams'] as JsonMap? ?? const {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted hashes used by the Twitch web Follow button (same as Xtra).
|
||||||
|
static const _followUserHash =
|
||||||
|
'800e7346bdf7e5278a3c1d3f21b2b56e2639928f86815677a7126b093b2fdd08';
|
||||||
|
static const _unfollowUserHash =
|
||||||
|
'f7dae976ebf41c755ae2d758546bfd176b4eeb856656098bb40e0a672ca0d880';
|
||||||
|
|
||||||
|
/// Follows [targetId] using the web GQL session. Helix no longer supports this.
|
||||||
|
///
|
||||||
|
/// Requires [authToken] from the Twitch web cookie (`gqlToken`). Some accounts
|
||||||
|
/// need a [integrityToken] + matching [deviceId]; callers should retry after
|
||||||
|
/// [fetchIntegrityToken] when [IntegrityCheckException] is thrown.
|
||||||
|
Future<void> followUser({
|
||||||
|
required String targetId,
|
||||||
|
required String authToken,
|
||||||
|
bool disableNotifications = false,
|
||||||
|
String? integrityToken,
|
||||||
|
String? deviceId,
|
||||||
|
}) async {
|
||||||
|
await _mutateFollow(
|
||||||
|
operationName: 'FollowButton_FollowUser',
|
||||||
|
sha256Hash: _followUserHash,
|
||||||
|
authToken: authToken,
|
||||||
|
integrityToken: integrityToken,
|
||||||
|
deviceId: deviceId,
|
||||||
|
variables: {
|
||||||
|
'input': {
|
||||||
|
'disableNotifications': disableNotifications,
|
||||||
|
'targetID': targetId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
successPath: const ['followUser'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unfollows [targetId] using the web GQL session.
|
||||||
|
Future<void> unfollowUser({
|
||||||
|
required String targetId,
|
||||||
|
required String authToken,
|
||||||
|
String? integrityToken,
|
||||||
|
String? deviceId,
|
||||||
|
}) async {
|
||||||
|
await _mutateFollow(
|
||||||
|
operationName: 'FollowButton_UnfollowUser',
|
||||||
|
sha256Hash: _unfollowUserHash,
|
||||||
|
authToken: authToken,
|
||||||
|
integrityToken: integrityToken,
|
||||||
|
deviceId: deviceId,
|
||||||
|
variables: {
|
||||||
|
'input': {'targetID': targetId},
|
||||||
|
},
|
||||||
|
successPath: const ['unfollowUser'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mints a Client-Integrity token for authenticated GQL mutations.
|
||||||
|
///
|
||||||
|
/// Bound to [deviceId] and [authToken]; send the same values on follow calls.
|
||||||
|
/// Tokens minted outside a real browser are usually flagged `is_bad_bot` —
|
||||||
|
/// those throw [IntegrityCheckException] so callers can fall back to a
|
||||||
|
/// WebView capture.
|
||||||
|
Future<({String token, int expirationMs})> fetchIntegrityToken({
|
||||||
|
required String authToken,
|
||||||
|
required String deviceId,
|
||||||
|
}) async {
|
||||||
|
final response = await post<JsonMap>(
|
||||||
|
'/integrity',
|
||||||
|
data: <String, dynamic>{},
|
||||||
|
headers: {
|
||||||
|
'Client-ID': twitchGqlClientId,
|
||||||
|
'Authorization': 'OAuth $authToken',
|
||||||
|
'X-Device-Id': deviceId,
|
||||||
|
'User-Agent': playbackUserAgent,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final token = response['token'] as String?;
|
||||||
|
final expiration = response['expiration'];
|
||||||
|
if (token == null || token.isEmpty) {
|
||||||
|
throw const ApiException('Twitch integrity token missing from response');
|
||||||
|
}
|
||||||
|
if (TwitchIntegrity.isBadBot(token)) {
|
||||||
|
throw const IntegrityCheckException(
|
||||||
|
'Twitch integrity token flagged as bad bot',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final expirationMs = expiration is int
|
||||||
|
? expiration
|
||||||
|
: int.tryParse('$expiration') ??
|
||||||
|
TwitchIntegrity.expirationMs(token) ??
|
||||||
|
0;
|
||||||
|
return (token: token, expirationMs: expirationMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _mutateFollow({
|
||||||
|
required String operationName,
|
||||||
|
required String sha256Hash,
|
||||||
|
required String authToken,
|
||||||
|
required JsonMap variables,
|
||||||
|
required List<String> successPath,
|
||||||
|
String? integrityToken,
|
||||||
|
String? deviceId,
|
||||||
|
}) async {
|
||||||
|
final headers = <String, String>{
|
||||||
|
'Client-ID': twitchGqlClientId,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'OAuth $authToken',
|
||||||
|
'User-Agent': playbackUserAgent,
|
||||||
|
if (deviceId != null && deviceId.isNotEmpty) 'X-Device-Id': deviceId,
|
||||||
|
if (integrityToken != null && integrityToken.isNotEmpty)
|
||||||
|
'Client-Integrity': integrityToken,
|
||||||
|
};
|
||||||
|
|
||||||
|
final response = await post<dynamic>(
|
||||||
|
'/gql',
|
||||||
|
data: {
|
||||||
|
'operationName': operationName,
|
||||||
|
'variables': variables,
|
||||||
|
'extensions': {
|
||||||
|
'persistedQuery': {
|
||||||
|
'version': 1,
|
||||||
|
'sha256Hash': sha256Hash,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
headers: headers,
|
||||||
|
);
|
||||||
|
|
||||||
|
final map = _gqlPayloadMap(response);
|
||||||
|
_throwIfGqlFailed(map);
|
||||||
|
|
||||||
|
JsonMap? cursor = map['data'] as JsonMap?;
|
||||||
|
for (final key in successPath) {
|
||||||
|
final next = cursor?[key];
|
||||||
|
if (next is JsonMap) {
|
||||||
|
cursor = next;
|
||||||
|
} else if (next == null) {
|
||||||
|
throw ApiException('Twitch $operationName returned no data');
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonMap _gqlPayloadMap(dynamic response) {
|
||||||
|
if (response is List) {
|
||||||
|
if (response.isEmpty || response.first is! Map) {
|
||||||
|
throw const ApiException('Unexpected Twitch GQL response');
|
||||||
|
}
|
||||||
|
return Map<String, dynamic>.from(response.first as Map);
|
||||||
|
}
|
||||||
|
if (response is Map) {
|
||||||
|
return Map<String, dynamic>.from(response);
|
||||||
|
}
|
||||||
|
throw const ApiException('Unexpected Twitch GQL response');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _throwIfGqlFailed(JsonMap map) {
|
||||||
|
final errors = map['errors'];
|
||||||
|
if (errors is! List || errors.isEmpty) return;
|
||||||
|
|
||||||
|
final message =
|
||||||
|
(errors.first as JsonMap)['message'] as String? ?? 'Twitch GQL error';
|
||||||
|
final lower = message.toLowerCase();
|
||||||
|
if (lower.contains('integrity')) {
|
||||||
|
throw IntegrityCheckException(message);
|
||||||
|
}
|
||||||
|
throw ApiException(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
StreamsTwitch _streamsFromConnection(JsonMap connection) {
|
||||||
|
final edges = (connection['edges'] as List?) ?? const [];
|
||||||
|
final pageInfo = connection['pageInfo'] as JsonMap?;
|
||||||
|
// Prefer explicit pageInfo; if missing, assume more when we got a full page.
|
||||||
|
final hasNext = pageInfo?['hasNextPage'] as bool? ?? edges.length >= 30;
|
||||||
|
|
||||||
|
final streams = <StreamTwitch>[];
|
||||||
|
String? nextCursor;
|
||||||
|
|
||||||
|
for (final edge in edges) {
|
||||||
|
final edgeMap = edge as JsonMap;
|
||||||
|
nextCursor = edgeMap['cursor'] as String?;
|
||||||
|
final node = edgeMap['node'] as JsonMap?;
|
||||||
|
if (node == null) continue;
|
||||||
|
streams.add(_streamFromNode(node));
|
||||||
|
}
|
||||||
|
|
||||||
|
return StreamsTwitch(
|
||||||
|
streams,
|
||||||
|
hasNext && nextCursor != null && nextCursor.isNotEmpty
|
||||||
|
? {'cursor': nextCursor}
|
||||||
|
: const {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
StreamTwitch _streamFromNode(JsonMap node) {
|
||||||
|
final broadcaster = node['broadcaster'] as JsonMap? ?? const {};
|
||||||
|
final game = node['game'] as JsonMap? ?? const {};
|
||||||
|
final login = broadcaster['login'] as String? ?? '';
|
||||||
|
final language = (node['language'] as String? ?? '').toLowerCase();
|
||||||
|
|
||||||
|
return StreamTwitch(
|
||||||
|
broadcaster['id'] as String? ?? '',
|
||||||
|
login,
|
||||||
|
broadcaster['displayName'] as String? ?? login,
|
||||||
|
game['id'] as String? ?? '',
|
||||||
|
game['name'] as String? ?? '',
|
||||||
|
node['title'] as String? ?? '',
|
||||||
|
(node['viewersCount'] as num?)?.toInt() ?? 0,
|
||||||
|
node['createdAt'] as String? ?? '',
|
||||||
|
// Helix-style placeholder so existing thumbnail widgets keep working.
|
||||||
|
'https://static-cdn.jtvnw.net/previews-ttv/live_user_$login-{width}x{height}.jpg',
|
||||||
|
language,
|
||||||
|
const [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
lib/apis/twitch_integrity.dart
Normal file
81
lib/apis/twitch_integrity.dart
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
/// Helpers for Twitch `Client-Integrity` tokens (`v4.public.*` / `v4.local.*`).
|
||||||
|
class TwitchIntegrity {
|
||||||
|
const TwitchIntegrity._();
|
||||||
|
|
||||||
|
/// Decodes the JSON payload embedded in an integrity token, if present.
|
||||||
|
static Map<String, dynamic>? decodePayload(String token) {
|
||||||
|
final parts = token.split('.');
|
||||||
|
if (parts.length < 2) return null;
|
||||||
|
|
||||||
|
for (final part in parts.skip(1)) {
|
||||||
|
if (part.isEmpty) continue;
|
||||||
|
try {
|
||||||
|
final normalized = base64Url.normalize(part);
|
||||||
|
final decoded = utf8.decode(base64Url.decode(normalized));
|
||||||
|
final dynamic json = jsonDecode(decoded);
|
||||||
|
if (json is Map &&
|
||||||
|
(json.containsKey('is_bad_bot') ||
|
||||||
|
json.containsKey('client_id') ||
|
||||||
|
json.containsKey('device_id'))) {
|
||||||
|
return Map<String, dynamic>.from(json);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Try the next segment — token layout varies by version.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dio/`POST /integrity` tokens from non-browser clients are usually flagged.
|
||||||
|
static bool isBadBot(String token) {
|
||||||
|
final payload = decodePayload(token);
|
||||||
|
if (payload == null) return false;
|
||||||
|
final flag = payload['is_bad_bot'];
|
||||||
|
if (flag is bool) return flag;
|
||||||
|
if (flag is String) return flag.toLowerCase() == 'true';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expiration in epoch millis from the payload, if present.
|
||||||
|
static int? expirationMs(String token) {
|
||||||
|
final payload = decodePayload(token);
|
||||||
|
if (payload == null) return null;
|
||||||
|
|
||||||
|
final exp = payload['exp'];
|
||||||
|
if (exp is int) {
|
||||||
|
// Already millis if large; seconds if typical JWT.
|
||||||
|
return exp > 100000000000 ? exp : exp * 1000;
|
||||||
|
}
|
||||||
|
if (exp is String) {
|
||||||
|
final asInt = int.tryParse(exp);
|
||||||
|
if (asInt != null) {
|
||||||
|
return asInt > 100000000000 ? asInt : asInt * 1000;
|
||||||
|
}
|
||||||
|
final dt = DateTime.tryParse(exp);
|
||||||
|
if (dt != null) return dt.millisecondsSinceEpoch;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Device id the token was minted for (`X-Device-Id` must match).
|
||||||
|
static String? deviceId(String token) {
|
||||||
|
final payload = decodePayload(token);
|
||||||
|
final id = payload?['device_id'];
|
||||||
|
return id is String && id.isNotEmpty ? id : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Credentials captured from a browser/WebView GQL request.
|
||||||
|
class GqlIntegrityCredentials {
|
||||||
|
final String integrityToken;
|
||||||
|
final String deviceId;
|
||||||
|
final int expirationMs;
|
||||||
|
|
||||||
|
const GqlIntegrityCredentials({
|
||||||
|
required this.integrityToken,
|
||||||
|
required this.deviceId,
|
||||||
|
required this.expirationMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
143
lib/apis/unauthorized_interceptor.dart
Normal file
143
lib/apis/unauthorized_interceptor.dart
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:frosty/main.dart';
|
||||||
|
import 'package:frosty/screens/onboarding/login_webview.dart';
|
||||||
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
|
import 'package:frosty/utils/twitch_oauth_scopes.dart';
|
||||||
|
import 'package:frosty/widgets/frosty_dialog.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
/// Dio interceptor that catches 401 Unauthorized errors and shows a login dialog
|
||||||
|
class UnauthorizedInterceptor extends Interceptor {
|
||||||
|
final AuthStore _authStore;
|
||||||
|
|
||||||
|
UnauthorizedInterceptor(this._authStore);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||||
|
// Check if this is a 401 Unauthorized error on a Twitch Helix/OAuth request
|
||||||
|
if (err.response?.statusCode == 401 &&
|
||||||
|
_isTwitchHelixOrOAuth(err.requestOptions.uri)) {
|
||||||
|
// For token validation requests, let the error propagate so validateToken can handle it
|
||||||
|
if (err.requestOptions.uri.path.endsWith('/validate')) {
|
||||||
|
handler.next(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final path = err.requestOptions.uri.path;
|
||||||
|
|
||||||
|
// Background moderated-channels fetches must never nag — especially for
|
||||||
|
// core-only tokens after an app upgrade.
|
||||||
|
if (isTwitchModeratedChannelsPath(path)) {
|
||||||
|
handler.reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moderator actions without scopes → opt-in Enable flow, not a generic
|
||||||
|
// "missing permissions" re-login for core scopes.
|
||||||
|
if (isTwitchModeratorActionPath(path) &&
|
||||||
|
!_authStore.hasModeratorScopes) {
|
||||||
|
_showAuthDialog(
|
||||||
|
title: 'Enable moderator tools',
|
||||||
|
message: enableModeratorToolsDialogMessage(),
|
||||||
|
primaryLabel: 'Enable',
|
||||||
|
onPrimary: () => openEnableModeratorTools(_authStore),
|
||||||
|
);
|
||||||
|
handler.reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Core-only messaging: if core scopes are present, prefer "Session expired"
|
||||||
|
// over "Missing permissions" (401 ≠ always missing scope).
|
||||||
|
final isLoggedIn = _authStore.isLoggedIn;
|
||||||
|
final missingCore = missingTwitchScopes(
|
||||||
|
granted: _authStore.grantedScopes,
|
||||||
|
);
|
||||||
|
_showAuthDialog(
|
||||||
|
title: isLoggedIn && missingCore.isNotEmpty
|
||||||
|
? 'Missing permissions'
|
||||||
|
: 'Session expired',
|
||||||
|
message: unauthorizedDialogMessage(
|
||||||
|
isLoggedIn: isLoggedIn,
|
||||||
|
grantedScopes: _authStore.grantedScopes,
|
||||||
|
),
|
||||||
|
primaryLabel: 'Log in',
|
||||||
|
onPrimary: () {
|
||||||
|
final context = navigatorKey.currentContext;
|
||||||
|
if (context == null) return;
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => Provider<AuthStore>.value(
|
||||||
|
value: _authStore,
|
||||||
|
child: const LoginWebView(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
handler.reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-401 errors, continue with normal error handling
|
||||||
|
handler.next(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAuthDialog({
|
||||||
|
required String title,
|
||||||
|
required String message,
|
||||||
|
required String primaryLabel,
|
||||||
|
required VoidCallback onPrimary,
|
||||||
|
}) {
|
||||||
|
final context = navigatorKey.currentContext;
|
||||||
|
if (context == null) return;
|
||||||
|
if (_isDialogShowing) return;
|
||||||
|
_isDialogShowing = true;
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (BuildContext dialogContext) {
|
||||||
|
return FrostyDialog(
|
||||||
|
title: title,
|
||||||
|
message: message,
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
_isDialogShowing = false;
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
},
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
_isDialogShowing = false;
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
onPrimary();
|
||||||
|
},
|
||||||
|
child: Text(primaryLabel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
).then((_) {
|
||||||
|
_isDialogShowing = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _isDialogShowing = false;
|
||||||
|
|
||||||
|
bool _isTwitchHelixOrOAuth(Uri uri) {
|
||||||
|
final url = uri.toString();
|
||||||
|
return url.startsWith('https://api.twitch.tv/helix') ||
|
||||||
|
url.startsWith('https://id.twitch.tv/oauth2');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helix path for listing channels the user moderates.
|
||||||
|
bool isTwitchModeratedChannelsPath(String path) =>
|
||||||
|
path.contains('/moderation/channels');
|
||||||
|
|
||||||
|
/// Helix paths for delete / ban / timeout / unban.
|
||||||
|
bool isTwitchModeratorActionPath(String path) =>
|
||||||
|
path.contains('/moderation/chat') || path.contains('/moderation/bans');
|
||||||
|
|
@ -1,6 +1,20 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Twitch API client ID.
|
/// Twitch API client ID.
|
||||||
const clientId = String.fromEnvironment('CLIENT_ID');
|
const clientId = String.fromEnvironment('CLIENT_ID');
|
||||||
|
|
||||||
|
/// Twitch GQL client ID (web player, used for playback access tokens).
|
||||||
|
const twitchGqlClientId = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
|
||||||
|
|
||||||
|
/// SharedPreferences key for the per-channel last-selected stream quality.
|
||||||
|
/// A saved variant that doesn't exist on the target channel (e.g. 1080p on a
|
||||||
|
/// source-only channel) falls back to Auto via the quality-list lookup.
|
||||||
|
String lastStreamQualityKey(String userLogin) =>
|
||||||
|
'last_stream_quality_$userLogin';
|
||||||
|
|
||||||
|
/// Legacy global key. Cleared on startup during SharedPreferences init.
|
||||||
|
const kLegacyLastStreamQualityKey = 'last_stream_quality';
|
||||||
|
|
||||||
/// Twitch API client secret.
|
/// Twitch API client secret.
|
||||||
const secret = String.fromEnvironment('SECRET');
|
const secret = String.fromEnvironment('SECRET');
|
||||||
|
|
||||||
|
|
@ -19,13 +33,70 @@ const zeroWidthEmotes = [
|
||||||
/// Regex for matching strings that contain lower or upper case English characters.
|
/// Regex for matching strings that contain lower or upper case English characters.
|
||||||
final regexEnglish = RegExp(r'[a-zA-Z]');
|
final regexEnglish = RegExp(r'[a-zA-Z]');
|
||||||
|
|
||||||
|
/// Regex for matching strings that contain only numeric characters.
|
||||||
|
final regexNumbersOnly = RegExp(r'^\d+$');
|
||||||
|
|
||||||
|
/// Regex for matching URLs and file names in text.
|
||||||
final regexLink = RegExp(
|
final regexLink = RegExp(
|
||||||
r'(?:https?:\/\/)?(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/\/=]*)',
|
r'(?<![A-Za-z0-9_.-])' // left boundary
|
||||||
caseSensitive: false, // Make it case-insensitive
|
r'(?:' // ───────── URL ─────────
|
||||||
|
r'(?:www\.)?' // optional www.
|
||||||
|
r'(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+'
|
||||||
|
r'[A-Za-z]{2,63}' // TLD
|
||||||
|
r'(?::\d{1,5})?' // optional :port
|
||||||
|
r'(?:/[^\s]*)?' // optional path / query / hash
|
||||||
|
r'|' // ───── file names ──────
|
||||||
|
r'[A-Za-z0-9_-]+\.(?:' // bare `doom.exe`, `logo.png`, …
|
||||||
|
r'exe|png|jpe?g|gif|bmp|webp|mp4|avi|zip|rar|pdf'
|
||||||
|
r')'
|
||||||
|
r')'
|
||||||
|
r'(?![A-Za-z0-9-])', // right boundary
|
||||||
|
caseSensitive: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Standard corner radius for card thumbnails and box art.
|
||||||
|
const kCardBorderRadius = BorderRadius.all(Radius.circular(8));
|
||||||
|
|
||||||
/// The default badge width and height.
|
/// The default badge width and height.
|
||||||
const defaultBadgeSize = 18.0;
|
const defaultBadgeSize = 18.0;
|
||||||
|
|
||||||
/// The default emote width and height when none are provided.
|
/// The default emote width and height when none are provided.
|
||||||
const defaultEmoteSize = 28.0;
|
const defaultEmoteSize = 28.0;
|
||||||
|
|
||||||
|
/// Available named chat colors for Twitch users.
|
||||||
|
const chatColorNames = [
|
||||||
|
'blue',
|
||||||
|
'blue_violet',
|
||||||
|
'cadet_blue',
|
||||||
|
'chocolate',
|
||||||
|
'coral',
|
||||||
|
'dodger_blue',
|
||||||
|
'firebrick',
|
||||||
|
'golden_rod',
|
||||||
|
'green',
|
||||||
|
'hot_pink',
|
||||||
|
'orange_red',
|
||||||
|
'red',
|
||||||
|
'sea_green',
|
||||||
|
'spring_green',
|
||||||
|
'yellow_green',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Color values for the named chat colors.
|
||||||
|
const Map<String, Color> chatColorValues = {
|
||||||
|
'blue': Color(0xFF0000FF),
|
||||||
|
'blue_violet': Color(0xFF8A2BE2),
|
||||||
|
'cadet_blue': Color(0xFF5F9EA0),
|
||||||
|
'chocolate': Color(0xFFD2691E),
|
||||||
|
'coral': Color(0xFFFF7F50),
|
||||||
|
'dodger_blue': Color(0xFF1E90FF),
|
||||||
|
'firebrick': Color(0xFFB22222),
|
||||||
|
'golden_rod': Color(0xFFDAA520),
|
||||||
|
'green': Color(0xFF008000),
|
||||||
|
'hot_pink': Color(0xFFFF69B4),
|
||||||
|
'orange_red': Color(0xFFFF4500),
|
||||||
|
'red': Color(0xFFFF0000),
|
||||||
|
'sea_green': Color(0xFF2E8B57),
|
||||||
|
'spring_green': Color(0xFF00FF7F),
|
||||||
|
'yellow_green': Color(0xFF9ACD32),
|
||||||
|
};
|
||||||
|
|
|
||||||
282
lib/main.dart
282
lib/main.dart
|
|
@ -1,58 +1,125 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:advanced_in_app_review/advanced_in_app_review.dart';
|
import 'package:advanced_in_app_review/advanced_in_app_review.dart';
|
||||||
|
import 'package:app_links/app_links.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||||
|
import 'package:firebase_performance/firebase_performance.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:frosty/apis/base_api_client.dart' show ApiException;
|
||||||
import 'package:frosty/apis/bttv_api.dart';
|
import 'package:frosty/apis/bttv_api.dart';
|
||||||
|
import 'package:frosty/apis/dio_client.dart';
|
||||||
import 'package:frosty/apis/ffz_api.dart';
|
import 'package:frosty/apis/ffz_api.dart';
|
||||||
import 'package:frosty/apis/seventv_api.dart';
|
import 'package:frosty/apis/seventv_api.dart';
|
||||||
import 'package:frosty/apis/twitch_api.dart';
|
import 'package:frosty/apis/twitch_api.dart';
|
||||||
|
import 'package:frosty/apis/twitch_auth_interceptor.dart';
|
||||||
|
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||||
|
import 'package:frosty/apis/unauthorized_interceptor.dart';
|
||||||
import 'package:frosty/cache_manager.dart';
|
import 'package:frosty/cache_manager.dart';
|
||||||
|
import 'package:frosty/constants.dart';
|
||||||
import 'package:frosty/firebase_options.dart';
|
import 'package:frosty/firebase_options.dart';
|
||||||
|
import 'package:frosty/screens/channel/channel.dart';
|
||||||
import 'package:frosty/screens/home/home.dart';
|
import 'package:frosty/screens/home/home.dart';
|
||||||
import 'package:frosty/screens/onboarding/onboarding_intro.dart';
|
import 'package:frosty/screens/onboarding/onboarding_intro.dart';
|
||||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||||
|
import 'package:frosty/stores/global_assets_store.dart';
|
||||||
import 'package:frosty/theme.dart';
|
import 'package:frosty/theme.dart';
|
||||||
import 'package:frosty/utils.dart';
|
import 'package:frosty/utils.dart';
|
||||||
import 'package:http/http.dart';
|
import 'package:frosty/widgets/alert_message.dart';
|
||||||
import 'package:mobx/mobx.dart';
|
import 'package:mobx/mobx.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
|
||||||
|
/// Errors that are expected on a mobile client — transient network/I/O, plugin
|
||||||
|
/// teardown races, and image fetch failures — and therefore shouldn't be
|
||||||
|
/// reported to Crashlytics as fatal crashes (which would tank the crash-free
|
||||||
|
/// metric and bucket unrelated errors together under the global handlers).
|
||||||
|
///
|
||||||
|
/// This is a last-resort net. The layers that own these errors already record
|
||||||
|
/// them non-fatally with precise reasons (e.g. base_api_client, the IRC
|
||||||
|
/// websocket handlers); this only catches what escapes that local handling.
|
||||||
|
bool _isNonFatalError(Object error) {
|
||||||
|
return error is SocketException ||
|
||||||
|
error is HttpException ||
|
||||||
|
error is WebSocketChannelException ||
|
||||||
|
error is ApiException ||
|
||||||
|
error is MissingPluginException ||
|
||||||
|
error is NetworkImageLoadException ||
|
||||||
|
(error is DioException && error.type != DioExceptionType.badResponse);
|
||||||
|
}
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
CustomCacheManager.removeOrphanedCacheFiles();
|
CustomCacheManager.removeOrphanedCacheFiles();
|
||||||
|
|
||||||
await Firebase.initializeApp(
|
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||||
options: DefaultFirebaseOptions.currentPlatform,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Pass all uncaught "fatal" errors from the framework to Crashlytics
|
// Route uncaught framework errors to Crashlytics. Non-crashing UI errors
|
||||||
FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;
|
// (layout overflows, image-load failures) and other expected transient
|
||||||
|
// failures are recorded as non-fatal so they don't inflate the crash count.
|
||||||
|
FlutterError.onError = (details) {
|
||||||
|
// Run the cheap type checks first; only fall back to the diagnostics string
|
||||||
|
// (RenderFlex overflow has no typed signal) when those miss.
|
||||||
|
final isNonFatal =
|
||||||
|
_isNonFatalError(details.exception) ||
|
||||||
|
details.exceptionAsString().contains('overflowed');
|
||||||
|
if (isNonFatal) {
|
||||||
|
FirebaseCrashlytics.instance.recordFlutterError(details);
|
||||||
|
} else {
|
||||||
|
FirebaseCrashlytics.instance.recordFlutterFatalError(details);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Pass all uncaught asynchronous errors that aren't handled by the Flutter framework to Crashlytics
|
// Pass uncaught asynchronous errors to Crashlytics. Expected transient errors
|
||||||
|
// are recorded non-fatally; everything else stays fatal. The reason gives the
|
||||||
|
// grouping engine a real signal so distinct errors don't collapse into one
|
||||||
|
// catch-all issue under this handler frame.
|
||||||
PlatformDispatcher.instance.onError = (error, stack) {
|
PlatformDispatcher.instance.onError = (error, stack) {
|
||||||
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
|
FirebaseCrashlytics.instance.recordError(
|
||||||
|
error,
|
||||||
|
stack,
|
||||||
|
reason: error.runtimeType.toString(),
|
||||||
|
fatal: !_isNonFatalError(error),
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
// One-time migration: the quality preference used to be stored under a
|
||||||
|
// single global key, which meant a saved 1080p60 from one channel could
|
||||||
|
// pin another channel to a variant it doesn't serve. Remove the legacy
|
||||||
|
// key; new writes go under per-channel keys.
|
||||||
|
await prefs.remove(kLegacyLastStreamQualityKey);
|
||||||
|
|
||||||
final firstRun = prefs.getBool('first_run') ?? true;
|
final firstRun = prefs.getBool('first_run') ?? true;
|
||||||
|
|
||||||
// Workaround for clearing stored tokens on uninstall.
|
// Workaround for clearing stored tokens on uninstall.
|
||||||
// If first time running app, will clear all tokens in the secure storage.
|
// If first time running app, will clear all tokens in the secure storage.
|
||||||
|
// Run non-blocking to avoid ANR on slow Android devices (especially Android 14).
|
||||||
if (firstRun) {
|
if (firstRun) {
|
||||||
debugPrint('Clearing secure storage...');
|
debugPrint('Clearing secure storage...');
|
||||||
const storage = FlutterSecureStorage();
|
const storage = FlutterSecureStorage();
|
||||||
|
|
||||||
await storage.deleteAll();
|
// Don't block - run in background after app starts.
|
||||||
|
// This is safe because first run means no active session depends on this data.
|
||||||
|
unawaited(
|
||||||
|
storage.deleteAll().catchError((e) {
|
||||||
|
debugPrint('Error clearing secure storage: $e');
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await initUtils();
|
await initUtils();
|
||||||
|
|
@ -64,30 +131,70 @@ void main() async {
|
||||||
// Initialize a settings store from the settings JSON string.
|
// Initialize a settings store from the settings JSON string.
|
||||||
final settingsStore = SettingsStore.fromJson(jsonDecode(userSettings));
|
final settingsStore = SettingsStore.fromJson(jsonDecode(userSettings));
|
||||||
|
|
||||||
|
// Disable Firebase collection in debug builds to avoid polluting production data.
|
||||||
|
// In release builds, respect the user's preference.
|
||||||
|
if (kDebugMode) {
|
||||||
|
FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(false);
|
||||||
|
FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(false);
|
||||||
|
FirebasePerformance.instance.setPerformanceCollectionEnabled(false);
|
||||||
|
} else {
|
||||||
|
final shareEnabled = settingsStore.shareCrashLogsAndAnalytics;
|
||||||
|
FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(shareEnabled);
|
||||||
|
FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(shareEnabled);
|
||||||
|
FirebasePerformance.instance.setPerformanceCollectionEnabled(shareEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
// Create a MobX reaction that will save the settings on disk every time they are changed.
|
// Create a MobX reaction that will save the settings on disk every time they are changed.
|
||||||
autorun((_) => prefs.setString('settings', jsonEncode(settingsStore)));
|
autorun((_) => prefs.setString('settings', jsonEncode(settingsStore)));
|
||||||
|
|
||||||
/// Initialize API services with a common client.
|
/// Initialize API services with a common Dio client.
|
||||||
/// This will prevent every request from creating a new client instance.
|
/// This will prevent every request from creating a new client instance.
|
||||||
final client = Client();
|
final dioClient = DioClient.createClient();
|
||||||
final twitchApiService = TwitchApi(client);
|
|
||||||
final bttvApiService = BTTVApi(client);
|
// Create API services
|
||||||
final ffzApiService = FFZApi(client);
|
final twitchApiService = TwitchApi(dioClient);
|
||||||
final sevenTVApiService = SevenTVApi(client);
|
final bttvApiService = BTTVApi(dioClient);
|
||||||
|
final ffzApiService = FFZApi(dioClient);
|
||||||
|
final sevenTVApiService = SevenTVApi(dioClient);
|
||||||
|
final twitchGqlApiService = TwitchGqlApi(dioClient);
|
||||||
|
|
||||||
|
// Create global assets store (shared cache for global emotes/badges)
|
||||||
|
final globalAssetsStore = GlobalAssetsStore(
|
||||||
|
twitchApi: twitchApiService,
|
||||||
|
bttvApi: bttvApiService,
|
||||||
|
ffzApi: ffzApiService,
|
||||||
|
sevenTVApi: sevenTVApiService,
|
||||||
|
);
|
||||||
|
|
||||||
// Create and initialize the authentication store
|
// Create and initialize the authentication store
|
||||||
final authStore = AuthStore(twitchApi: twitchApiService);
|
final authStore = AuthStore(
|
||||||
|
twitchApi: twitchApiService,
|
||||||
|
twitchGqlApi: twitchGqlApiService,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add the auth interceptor to the Dio client after AuthStore creation
|
||||||
|
dioClient.interceptors.add(TwitchAuthInterceptor(authStore));
|
||||||
|
|
||||||
|
// Add the unauthorized interceptor to catch 401 errors
|
||||||
|
dioClient.interceptors.add(UnauthorizedInterceptor(authStore));
|
||||||
|
|
||||||
await authStore.init();
|
await authStore.init();
|
||||||
|
FirebaseCrashlytics.instance.setCustomKey('is_logged_in', authStore.isLoggedIn);
|
||||||
|
if (authStore.isLoggedIn && authStore.user.details != null) {
|
||||||
|
FirebaseCrashlytics.instance.setUserIdentifier(authStore.user.details!.id);
|
||||||
|
}
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
MultiProvider(
|
MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
Provider<AuthStore>(create: (_) => authStore),
|
Provider<AuthStore>.value(value: authStore),
|
||||||
Provider<SettingsStore>(create: (_) => settingsStore),
|
Provider<SettingsStore>.value(value: settingsStore),
|
||||||
Provider<TwitchApi>(create: (_) => twitchApiService),
|
Provider<TwitchApi>.value(value: twitchApiService),
|
||||||
Provider<BTTVApi>(create: (_) => bttvApiService),
|
Provider<BTTVApi>.value(value: bttvApiService),
|
||||||
Provider<FFZApi>(create: (_) => ffzApiService),
|
Provider<FFZApi>.value(value: ffzApiService),
|
||||||
Provider<SevenTVApi>(create: (_) => sevenTVApiService),
|
Provider<SevenTVApi>.value(value: sevenTVApiService),
|
||||||
|
Provider<TwitchGqlApi>.value(value: twitchGqlApiService),
|
||||||
|
Provider<GlobalAssetsStore>.value(value: globalAssetsStore),
|
||||||
],
|
],
|
||||||
child: MyApp(firstRun: firstRun),
|
child: MyApp(firstRun: firstRun),
|
||||||
),
|
),
|
||||||
|
|
@ -100,16 +207,16 @@ final navigatorKey = GlobalKey<NavigatorState>();
|
||||||
class MyApp extends StatefulWidget {
|
class MyApp extends StatefulWidget {
|
||||||
final bool firstRun;
|
final bool firstRun;
|
||||||
|
|
||||||
const MyApp({
|
const MyApp({super.key, this.firstRun = false});
|
||||||
super.key,
|
|
||||||
this.firstRun = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MyApp> createState() => _MyAppState();
|
State<MyApp> createState() => _MyAppState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MyAppState extends State<MyApp> {
|
class _MyAppState extends State<MyApp> {
|
||||||
|
final AppLinks _appLinks = AppLinks();
|
||||||
|
StreamSubscription<Uri>? _linkSubscription;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -120,6 +227,8 @@ class _MyAppState extends State<MyApp> {
|
||||||
.setMinLaunchTimes(5)
|
.setMinLaunchTimes(5)
|
||||||
.setMinSecondsBeforeShowDialog(3)
|
.setMinSecondsBeforeShowDialog(3)
|
||||||
.monitor();
|
.monitor();
|
||||||
|
|
||||||
|
_initDeepLinks();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -127,25 +236,114 @@ class _MyAppState extends State<MyApp> {
|
||||||
return Observer(
|
return Observer(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final settingsStore = context.read<SettingsStore>();
|
final settingsStore = context.read<SettingsStore>();
|
||||||
final themes =
|
final themes = FrostyThemes(
|
||||||
FrostyThemes(colorSchemeSeed: Color(settingsStore.accentColor));
|
colorSchemeSeed: Color(settingsStore.accentColor),
|
||||||
|
);
|
||||||
|
|
||||||
return Provider<FrostyThemes>(
|
return MaterialApp(
|
||||||
create: (_) => themes,
|
title: 'Frosty',
|
||||||
child: MaterialApp(
|
theme: themes.light,
|
||||||
title: 'Frosty',
|
darkTheme: themes.dark,
|
||||||
theme: themes.light,
|
themeMode: settingsStore.themeType == ThemeType.system
|
||||||
darkTheme: themes.dark,
|
? ThemeMode.system
|
||||||
themeMode: settingsStore.themeType == ThemeType.system
|
: settingsStore.themeType == ThemeType.light
|
||||||
? ThemeMode.system
|
? ThemeMode.light
|
||||||
: settingsStore.themeType == ThemeType.light
|
: ThemeMode.dark,
|
||||||
? ThemeMode.light
|
navigatorObservers: [
|
||||||
: ThemeMode.dark,
|
FirebaseAnalyticsObserver(
|
||||||
home: widget.firstRun ? const OnboardingIntro() : const Home(),
|
analytics: FirebaseAnalytics.instance,
|
||||||
navigatorKey: navigatorKey,
|
),
|
||||||
),
|
],
|
||||||
|
home: widget.firstRun ? const OnboardingIntro() : const Home(),
|
||||||
|
navigatorKey: navigatorKey,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _initDeepLinks() async {
|
||||||
|
try {
|
||||||
|
// Handle links when app is already open
|
||||||
|
_linkSubscription = _appLinks.uriLinkStream.listen((uri) {
|
||||||
|
handleDeepLink(uri);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle the initial link if app was opened from a link.
|
||||||
|
// Add timeout to prevent indefinite blocking on certain Android lifecycle states.
|
||||||
|
final initialLink = await _appLinks.getInitialLink().timeout(
|
||||||
|
const Duration(seconds: 3),
|
||||||
|
onTimeout: () => null,
|
||||||
|
);
|
||||||
|
if (initialLink != null) {
|
||||||
|
handleDeepLink(initialLink);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to initialize deep links: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> handleDeepLink(Uri uri) async {
|
||||||
|
final failureSnackbar = SnackBar(
|
||||||
|
content: AlertMessage(
|
||||||
|
message: 'Unable to navigate to \'$uri\'',
|
||||||
|
centered: false,
|
||||||
|
trailingIcon: Icons.open_in_browser_rounded,
|
||||||
|
// Fallback, allow user to open URL outside app
|
||||||
|
onTrailingIconPressed: () async {
|
||||||
|
await launchUrl(
|
||||||
|
uri,
|
||||||
|
mode: LaunchMode.inAppWebView, // Force browser
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle channel links
|
||||||
|
if (uri.pathSegments.isNotEmpty) {
|
||||||
|
final channelName = uri.pathSegments.first;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final twitchApi = context.read<TwitchApi>();
|
||||||
|
|
||||||
|
final user = await twitchApi.getUser(userLogin: channelName);
|
||||||
|
|
||||||
|
final route = MaterialPageRoute(
|
||||||
|
settings: const RouteSettings(name: VideoChat.routeName),
|
||||||
|
builder: (context) => VideoChat(
|
||||||
|
userId: user.id,
|
||||||
|
userName: user.displayName,
|
||||||
|
userLogin: user.login,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (navigatorKey.currentState == null) return;
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback(
|
||||||
|
(_) => navigatorKey.currentState?.push(route),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// If we get here, there was most likely an error with the Twitch API call and/or this isn't really a channel link
|
||||||
|
debugPrint('Failed to open link $uri due to error: $e');
|
||||||
|
|
||||||
|
if (navigatorKey.currentContext == null) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
navigatorKey.currentContext!,
|
||||||
|
).showSnackBar(failureSnackbar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TODO: Here we can implement handlers for other types of links
|
||||||
|
else {
|
||||||
|
// If we get here, it's a link format that we're unable to handle
|
||||||
|
if (navigatorKey.currentContext == null) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
navigatorKey.currentContext!,
|
||||||
|
).showSnackBar(failureSnackbar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_linkSubscription?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,12 +35,7 @@ class BadgeInfoFFZ {
|
||||||
final String color;
|
final String color;
|
||||||
final BadgeUrlsFFZ urls;
|
final BadgeUrlsFFZ urls;
|
||||||
|
|
||||||
const BadgeInfoFFZ(
|
const BadgeInfoFFZ(this.id, this.title, this.color, this.urls);
|
||||||
this.id,
|
|
||||||
this.title,
|
|
||||||
this.color,
|
|
||||||
this.urls,
|
|
||||||
);
|
|
||||||
|
|
||||||
factory BadgeInfoFFZ.fromJson(Map<String, dynamic> json) =>
|
factory BadgeInfoFFZ.fromJson(Map<String, dynamic> json) =>
|
||||||
_$BadgeInfoFFZFromJson(json);
|
_$BadgeInfoFFZFromJson(json);
|
||||||
|
|
@ -55,11 +50,7 @@ class BadgeUrlsFFZ {
|
||||||
@JsonKey(name: '4')
|
@JsonKey(name: '4')
|
||||||
final String url4x;
|
final String url4x;
|
||||||
|
|
||||||
const BadgeUrlsFFZ(
|
const BadgeUrlsFFZ(this.url1x, this.url2x, this.url4x);
|
||||||
this.url1x,
|
|
||||||
this.url2x,
|
|
||||||
this.url4x,
|
|
||||||
);
|
|
||||||
|
|
||||||
factory BadgeUrlsFFZ.fromJson(Map<String, dynamic> json) =>
|
factory BadgeUrlsFFZ.fromJson(Map<String, dynamic> json) =>
|
||||||
_$BadgeUrlsFFZFromJson(json);
|
_$BadgeUrlsFFZFromJson(json);
|
||||||
|
|
@ -71,11 +62,7 @@ class BadgeInfo7TV {
|
||||||
final List<List<String>> urls;
|
final List<List<String>> urls;
|
||||||
final List<String> users;
|
final List<String> users;
|
||||||
|
|
||||||
BadgeInfo7TV(
|
BadgeInfo7TV(this.tooltip, this.urls, this.users);
|
||||||
this.tooltip,
|
|
||||||
this.urls,
|
|
||||||
this.users,
|
|
||||||
);
|
|
||||||
|
|
||||||
factory BadgeInfo7TV.fromJson(Map<String, dynamic> json) =>
|
factory BadgeInfo7TV.fromJson(Map<String, dynamic> json) =>
|
||||||
_$BadgeInfo7TVFromJson(json);
|
_$BadgeInfo7TVFromJson(json);
|
||||||
|
|
@ -86,10 +73,7 @@ class BadgeInfoBTTV {
|
||||||
final String providerId;
|
final String providerId;
|
||||||
final BadgeDetailsBTTV badge;
|
final BadgeDetailsBTTV badge;
|
||||||
|
|
||||||
BadgeInfoBTTV(
|
BadgeInfoBTTV(this.providerId, this.badge);
|
||||||
this.providerId,
|
|
||||||
this.badge,
|
|
||||||
);
|
|
||||||
|
|
||||||
factory BadgeInfoBTTV.fromJson(Map<String, dynamic> json) =>
|
factory BadgeInfoBTTV.fromJson(Map<String, dynamic> json) =>
|
||||||
_$BadgeInfoBTTVFromJson(json);
|
_$BadgeInfoBTTVFromJson(json);
|
||||||
|
|
@ -100,10 +84,7 @@ class BadgeDetailsBTTV {
|
||||||
final String description;
|
final String description;
|
||||||
final String svg;
|
final String svg;
|
||||||
|
|
||||||
BadgeDetailsBTTV(
|
BadgeDetailsBTTV(this.description, this.svg);
|
||||||
this.description,
|
|
||||||
this.svg,
|
|
||||||
);
|
|
||||||
|
|
||||||
factory BadgeDetailsBTTV.fromJson(Map<String, dynamic> json) =>
|
factory BadgeDetailsBTTV.fromJson(Map<String, dynamic> json) =>
|
||||||
_$BadgeDetailsBTTVFromJson(json);
|
_$BadgeDetailsBTTVFromJson(json);
|
||||||
|
|
@ -123,29 +104,29 @@ class ChatBadge {
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ChatBadge.fromTwitch(BadgeInfoTwitch badge) => ChatBadge(
|
factory ChatBadge.fromTwitch(BadgeInfoTwitch badge) => ChatBadge(
|
||||||
name: badge.title,
|
name: badge.title,
|
||||||
url: badge.imageUrl4x,
|
url: badge.imageUrl4x,
|
||||||
type: BadgeType.twitch,
|
type: BadgeType.twitch,
|
||||||
);
|
);
|
||||||
|
|
||||||
factory ChatBadge.fromBTTV(BadgeInfoBTTV badge) => ChatBadge(
|
factory ChatBadge.fromBTTV(BadgeInfoBTTV badge) => ChatBadge(
|
||||||
name: badge.badge.description,
|
name: badge.badge.description,
|
||||||
url: badge.badge.svg,
|
url: badge.badge.svg,
|
||||||
type: BadgeType.bttv,
|
type: BadgeType.bttv,
|
||||||
);
|
);
|
||||||
|
|
||||||
factory ChatBadge.fromFFZ(BadgeInfoFFZ badge) => ChatBadge(
|
factory ChatBadge.fromFFZ(BadgeInfoFFZ badge) => ChatBadge(
|
||||||
name: badge.title,
|
name: badge.title,
|
||||||
url: badge.urls.url4x,
|
url: badge.urls.url4x,
|
||||||
color: badge.color,
|
color: badge.color,
|
||||||
type: BadgeType.ffz,
|
type: BadgeType.ffz,
|
||||||
);
|
);
|
||||||
|
|
||||||
factory ChatBadge.from7TV(BadgeInfo7TV badge) => ChatBadge(
|
factory ChatBadge.from7TV(BadgeInfo7TV badge) => ChatBadge(
|
||||||
name: badge.tooltip,
|
name: badge.tooltip,
|
||||||
url: badge.urls[2][1],
|
url: badge.urls[2][1],
|
||||||
type: BadgeType.sevenTV,
|
type: BadgeType.sevenTV,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
enum BadgeType {
|
enum BadgeType {
|
||||||
|
|
|
||||||
|
|
@ -17,25 +17,22 @@ BadgeInfoTwitch _$BadgeInfoTwitchFromJson(Map<String, dynamic> json) =>
|
||||||
);
|
);
|
||||||
|
|
||||||
BadgeInfoFFZ _$BadgeInfoFFZFromJson(Map<String, dynamic> json) => BadgeInfoFFZ(
|
BadgeInfoFFZ _$BadgeInfoFFZFromJson(Map<String, dynamic> json) => BadgeInfoFFZ(
|
||||||
(json['id'] as num).toInt(),
|
(json['id'] as num).toInt(),
|
||||||
json['title'] as String,
|
json['title'] as String,
|
||||||
json['color'] as String,
|
json['color'] as String,
|
||||||
BadgeUrlsFFZ.fromJson(json['urls'] as Map<String, dynamic>),
|
BadgeUrlsFFZ.fromJson(json['urls'] as Map<String, dynamic>),
|
||||||
);
|
);
|
||||||
|
|
||||||
BadgeUrlsFFZ _$BadgeUrlsFFZFromJson(Map<String, dynamic> json) => BadgeUrlsFFZ(
|
BadgeUrlsFFZ _$BadgeUrlsFFZFromJson(Map<String, dynamic> json) =>
|
||||||
json['1'] as String,
|
BadgeUrlsFFZ(json['1'] as String, json['2'] as String, json['4'] as String);
|
||||||
json['2'] as String,
|
|
||||||
json['4'] as String,
|
|
||||||
);
|
|
||||||
|
|
||||||
BadgeInfo7TV _$BadgeInfo7TVFromJson(Map<String, dynamic> json) => BadgeInfo7TV(
|
BadgeInfo7TV _$BadgeInfo7TVFromJson(Map<String, dynamic> json) => BadgeInfo7TV(
|
||||||
json['tooltip'] as String,
|
json['tooltip'] as String,
|
||||||
(json['urls'] as List<dynamic>)
|
(json['urls'] as List<dynamic>)
|
||||||
.map((e) => (e as List<dynamic>).map((e) => e as String).toList())
|
.map((e) => (e as List<dynamic>).map((e) => e as String).toList())
|
||||||
.toList(),
|
.toList(),
|
||||||
(json['users'] as List<dynamic>).map((e) => e as String).toList(),
|
(json['users'] as List<dynamic>).map((e) => e as String).toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
BadgeInfoBTTV _$BadgeInfoBTTVFromJson(Map<String, dynamic> json) =>
|
BadgeInfoBTTV _$BadgeInfoBTTVFromJson(Map<String, dynamic> json) =>
|
||||||
BadgeInfoBTTV(
|
BadgeInfoBTTV(
|
||||||
|
|
@ -44,7 +41,4 @@ BadgeInfoBTTV _$BadgeInfoBTTVFromJson(Map<String, dynamic> json) =>
|
||||||
);
|
);
|
||||||
|
|
||||||
BadgeDetailsBTTV _$BadgeDetailsBTTVFromJson(Map<String, dynamic> json) =>
|
BadgeDetailsBTTV _$BadgeDetailsBTTVFromJson(Map<String, dynamic> json) =>
|
||||||
BadgeDetailsBTTV(
|
BadgeDetailsBTTV(json['description'] as String, json['svg'] as String);
|
||||||
json['description'] as String,
|
|
||||||
json['svg'] as String,
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,7 @@ class CategoryTwitch {
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
|
|
||||||
const CategoryTwitch(
|
const CategoryTwitch(this.boxArtUrl, this.id, this.name);
|
||||||
this.boxArtUrl,
|
|
||||||
this.id,
|
|
||||||
this.name,
|
|
||||||
);
|
|
||||||
|
|
||||||
factory CategoryTwitch.fromJson(Map<String, dynamic> json) =>
|
factory CategoryTwitch.fromJson(Map<String, dynamic> json) =>
|
||||||
_$CategoryTwitchFromJson(json);
|
_$CategoryTwitchFromJson(json);
|
||||||
|
|
@ -23,10 +19,7 @@ class CategoriesTwitch {
|
||||||
final List<CategoryTwitch> data;
|
final List<CategoryTwitch> data;
|
||||||
final Map<String, String>? pagination;
|
final Map<String, String>? pagination;
|
||||||
|
|
||||||
const CategoriesTwitch(
|
const CategoriesTwitch(this.data, this.pagination);
|
||||||
this.data,
|
|
||||||
this.pagination,
|
|
||||||
);
|
|
||||||
|
|
||||||
factory CategoriesTwitch.fromJson(Map<String, dynamic> json) =>
|
factory CategoriesTwitch.fromJson(Map<String, dynamic> json) =>
|
||||||
_$CategoriesTwitchFromJson(json);
|
_$CategoriesTwitchFromJson(json);
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,19 @@ class Channel {
|
||||||
final String broadcasterId;
|
final String broadcasterId;
|
||||||
final String broadcasterLogin;
|
final String broadcasterLogin;
|
||||||
final String broadcasterName;
|
final String broadcasterName;
|
||||||
|
final String broadcasterLanguage;
|
||||||
|
final String title;
|
||||||
|
final String gameId;
|
||||||
|
final String gameName;
|
||||||
|
|
||||||
const Channel({
|
const Channel({
|
||||||
required this.broadcasterId,
|
required this.broadcasterId,
|
||||||
required this.broadcasterLogin,
|
required this.broadcasterLogin,
|
||||||
required this.broadcasterName,
|
required this.broadcasterName,
|
||||||
|
required this.broadcasterLanguage,
|
||||||
|
required this.title,
|
||||||
|
required this.gameId,
|
||||||
|
required this.gameName,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Channel.fromJson(Map<String, dynamic> json) =>
|
factory Channel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,19 @@ part of 'channel.dart';
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
Channel _$ChannelFromJson(Map<String, dynamic> json) => Channel(
|
Channel _$ChannelFromJson(Map<String, dynamic> json) => Channel(
|
||||||
broadcasterId: json['broadcaster_id'] as String,
|
broadcasterId: json['broadcaster_id'] as String,
|
||||||
broadcasterLogin: json['broadcaster_login'] as String,
|
broadcasterLogin: json['broadcaster_login'] as String,
|
||||||
broadcasterName: json['broadcaster_name'] as String,
|
broadcasterName: json['broadcaster_name'] as String,
|
||||||
);
|
broadcasterLanguage: json['broadcaster_language'] as String,
|
||||||
|
title: json['title'] as String,
|
||||||
|
gameId: json['game_id'] as String,
|
||||||
|
gameName: json['game_name'] as String,
|
||||||
|
);
|
||||||
|
|
||||||
ChannelQuery _$ChannelQueryFromJson(Map<String, dynamic> json) => ChannelQuery(
|
ChannelQuery _$ChannelQueryFromJson(Map<String, dynamic> json) => ChannelQuery(
|
||||||
broadcasterLogin: json['broadcaster_login'] as String,
|
broadcasterLogin: json['broadcaster_login'] as String,
|
||||||
displayName: json['display_name'] as String,
|
displayName: json['display_name'] as String,
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
isLive: json['is_live'] as bool,
|
isLive: json['is_live'] as bool,
|
||||||
startedAt: json['started_at'] as String,
|
startedAt: json['started_at'] as String,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,7 @@ class ChatUsers {
|
||||||
final int chatterCount;
|
final int chatterCount;
|
||||||
final Chatters chatters;
|
final Chatters chatters;
|
||||||
|
|
||||||
ChatUsers(
|
ChatUsers(this.chatterCount, this.chatters);
|
||||||
this.chatterCount,
|
|
||||||
this.chatters,
|
|
||||||
);
|
|
||||||
|
|
||||||
factory ChatUsers.fromJson(Map<String, dynamic> json) =>
|
factory ChatUsers.fromJson(Map<String, dynamic> json) =>
|
||||||
_$ChatUsersFromJson(json);
|
_$ChatUsersFromJson(json);
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,16 @@ part of 'chatters.dart';
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
ChatUsers _$ChatUsersFromJson(Map<String, dynamic> json) => ChatUsers(
|
ChatUsers _$ChatUsersFromJson(Map<String, dynamic> json) => ChatUsers(
|
||||||
(json['chatter_count'] as num).toInt(),
|
(json['chatter_count'] as num).toInt(),
|
||||||
Chatters.fromJson(json['chatters'] as Map<String, dynamic>),
|
Chatters.fromJson(json['chatters'] as Map<String, dynamic>),
|
||||||
);
|
);
|
||||||
|
|
||||||
Chatters _$ChattersFromJson(Map<String, dynamic> json) => Chatters(
|
Chatters _$ChattersFromJson(Map<String, dynamic> json) => Chatters(
|
||||||
(json['broadcaster'] as List<dynamic>).map((e) => e as String).toList(),
|
(json['broadcaster'] as List<dynamic>).map((e) => e as String).toList(),
|
||||||
(json['vips'] as List<dynamic>).map((e) => e as String).toList(),
|
(json['vips'] as List<dynamic>).map((e) => e as String).toList(),
|
||||||
(json['moderators'] as List<dynamic>).map((e) => e as String).toList(),
|
(json['moderators'] as List<dynamic>).map((e) => e as String).toList(),
|
||||||
(json['staff'] as List<dynamic>).map((e) => e as String).toList(),
|
(json['staff'] as List<dynamic>).map((e) => e as String).toList(),
|
||||||
(json['admins'] as List<dynamic>).map((e) => e as String).toList(),
|
(json['admins'] as List<dynamic>).map((e) => e as String).toList(),
|
||||||
(json['global_mods'] as List<dynamic>).map((e) => e as String).toList(),
|
(json['global_mods'] as List<dynamic>).map((e) => e as String).toList(),
|
||||||
(json['viewers'] as List<dynamic>).map((e) => e as String).toList(),
|
(json['viewers'] as List<dynamic>).map((e) => e as String).toList(),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,7 @@ class EmoteTwitch {
|
||||||
final String? emoteType;
|
final String? emoteType;
|
||||||
final String? ownerId;
|
final String? ownerId;
|
||||||
|
|
||||||
const EmoteTwitch(
|
const EmoteTwitch(this.id, this.name, this.emoteType, this.ownerId);
|
||||||
this.id,
|
|
||||||
this.name,
|
|
||||||
this.emoteType,
|
|
||||||
this.ownerId,
|
|
||||||
);
|
|
||||||
|
|
||||||
factory EmoteTwitch.fromJson(Map<String, dynamic> json) =>
|
factory EmoteTwitch.fromJson(Map<String, dynamic> json) =>
|
||||||
_$EmoteTwitchFromJson(json);
|
_$EmoteTwitchFromJson(json);
|
||||||
|
|
@ -29,10 +24,7 @@ class EmoteBTTV {
|
||||||
final String id;
|
final String id;
|
||||||
final String code;
|
final String code;
|
||||||
|
|
||||||
const EmoteBTTV(
|
const EmoteBTTV(this.id, this.code);
|
||||||
this.id,
|
|
||||||
this.code,
|
|
||||||
);
|
|
||||||
|
|
||||||
factory EmoteBTTV.fromJson(Map<String, dynamic> json) =>
|
factory EmoteBTTV.fromJson(Map<String, dynamic> json) =>
|
||||||
_$EmoteBTTVFromJson(json);
|
_$EmoteBTTVFromJson(json);
|
||||||
|
|
@ -43,10 +35,7 @@ class EmoteBTTVChannel {
|
||||||
final List<EmoteBTTV> channelEmotes;
|
final List<EmoteBTTV> channelEmotes;
|
||||||
final List<EmoteBTTV> sharedEmotes;
|
final List<EmoteBTTV> sharedEmotes;
|
||||||
|
|
||||||
const EmoteBTTVChannel(
|
const EmoteBTTVChannel(this.channelEmotes, this.sharedEmotes);
|
||||||
this.channelEmotes,
|
|
||||||
this.sharedEmotes,
|
|
||||||
);
|
|
||||||
|
|
||||||
factory EmoteBTTVChannel.fromJson(Map<String, dynamic> json) =>
|
factory EmoteBTTVChannel.fromJson(Map<String, dynamic> json) =>
|
||||||
_$EmoteBTTVChannelFromJson(json);
|
_$EmoteBTTVChannelFromJson(json);
|
||||||
|
|
@ -59,11 +48,7 @@ class RoomFFZ {
|
||||||
final ImagesFFZ? vipBadge;
|
final ImagesFFZ? vipBadge;
|
||||||
final ImagesFFZ? modUrls;
|
final ImagesFFZ? modUrls;
|
||||||
|
|
||||||
const RoomFFZ(
|
const RoomFFZ(this.set, this.vipBadge, this.modUrls);
|
||||||
this.set,
|
|
||||||
this.vipBadge,
|
|
||||||
this.modUrls,
|
|
||||||
);
|
|
||||||
|
|
||||||
factory RoomFFZ.fromJson(Map<String, dynamic> json) =>
|
factory RoomFFZ.fromJson(Map<String, dynamic> json) =>
|
||||||
_$RoomFFZFromJson(json);
|
_$RoomFFZFromJson(json);
|
||||||
|
|
@ -78,11 +63,7 @@ class ImagesFFZ {
|
||||||
@JsonKey(name: '4')
|
@JsonKey(name: '4')
|
||||||
final String? url4x;
|
final String? url4x;
|
||||||
|
|
||||||
const ImagesFFZ(
|
const ImagesFFZ(this.url1x, this.url2x, this.url4x);
|
||||||
this.url1x,
|
|
||||||
this.url2x,
|
|
||||||
this.url4x,
|
|
||||||
);
|
|
||||||
|
|
||||||
factory ImagesFFZ.fromJson(Map<String, dynamic> json) =>
|
factory ImagesFFZ.fromJson(Map<String, dynamic> json) =>
|
||||||
_$ImagesFFZFromJson(json);
|
_$ImagesFFZFromJson(json);
|
||||||
|
|
@ -93,10 +74,7 @@ class OwnerFFZ {
|
||||||
final String displayName;
|
final String displayName;
|
||||||
final String name;
|
final String name;
|
||||||
|
|
||||||
const OwnerFFZ({
|
const OwnerFFZ({required this.displayName, required this.name});
|
||||||
required this.displayName,
|
|
||||||
required this.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory OwnerFFZ.fromJson(Map<String, dynamic> json) =>
|
factory OwnerFFZ.fromJson(Map<String, dynamic> json) =>
|
||||||
_$OwnerFFZFromJson(json);
|
_$OwnerFFZFromJson(json);
|
||||||
|
|
@ -130,11 +108,7 @@ class Emote7TV {
|
||||||
final String name;
|
final String name;
|
||||||
final Emote7TVData data;
|
final Emote7TVData data;
|
||||||
|
|
||||||
const Emote7TV(
|
const Emote7TV(this.id, this.name, this.data);
|
||||||
this.id,
|
|
||||||
this.name,
|
|
||||||
this.data,
|
|
||||||
);
|
|
||||||
|
|
||||||
factory Emote7TV.fromJson(Map<String, dynamic> json) =>
|
factory Emote7TV.fromJson(Map<String, dynamic> json) =>
|
||||||
_$Emote7TVFromJson(json);
|
_$Emote7TVFromJson(json);
|
||||||
|
|
@ -145,10 +119,7 @@ class Owner7TV {
|
||||||
final String username;
|
final String username;
|
||||||
final String displayName;
|
final String displayName;
|
||||||
|
|
||||||
const Owner7TV({
|
const Owner7TV({required this.username, required this.displayName});
|
||||||
required this.username,
|
|
||||||
required this.displayName,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory Owner7TV.fromJson(Map<String, dynamic> json) =>
|
factory Owner7TV.fromJson(Map<String, dynamic> json) =>
|
||||||
_$Owner7TVFromJson(json);
|
_$Owner7TVFromJson(json);
|
||||||
|
|
@ -162,13 +133,7 @@ class Emote7TVData {
|
||||||
final Owner7TV? owner;
|
final Owner7TV? owner;
|
||||||
final Emote7TVHost host;
|
final Emote7TVHost host;
|
||||||
|
|
||||||
const Emote7TVData(
|
const Emote7TVData(this.id, this.name, this.flags, this.owner, this.host);
|
||||||
this.id,
|
|
||||||
this.name,
|
|
||||||
this.flags,
|
|
||||||
this.owner,
|
|
||||||
this.host,
|
|
||||||
);
|
|
||||||
|
|
||||||
factory Emote7TVData.fromJson(Map<String, dynamic> json) =>
|
factory Emote7TVData.fromJson(Map<String, dynamic> json) =>
|
||||||
_$Emote7TVDataFromJson(json);
|
_$Emote7TVDataFromJson(json);
|
||||||
|
|
@ -179,10 +144,7 @@ class Emote7TVHost {
|
||||||
final String url;
|
final String url;
|
||||||
final List<Emote7TVFile> files;
|
final List<Emote7TVFile> files;
|
||||||
|
|
||||||
Emote7TVHost(
|
Emote7TVHost(this.url, this.files);
|
||||||
this.url,
|
|
||||||
this.files,
|
|
||||||
);
|
|
||||||
|
|
||||||
factory Emote7TVHost.fromJson(Map<String, dynamic> json) =>
|
factory Emote7TVHost.fromJson(Map<String, dynamic> json) =>
|
||||||
_$Emote7TVHostFromJson(json);
|
_$Emote7TVHostFromJson(json);
|
||||||
|
|
@ -195,12 +157,7 @@ class Emote7TVFile {
|
||||||
final int height;
|
final int height;
|
||||||
final String format;
|
final String format;
|
||||||
|
|
||||||
Emote7TVFile(
|
Emote7TVFile(this.name, this.width, this.height, this.format);
|
||||||
this.name,
|
|
||||||
this.width,
|
|
||||||
this.height,
|
|
||||||
this.format,
|
|
||||||
);
|
|
||||||
|
|
||||||
factory Emote7TVFile.fromJson(Map<String, dynamic> json) =>
|
factory Emote7TVFile.fromJson(Map<String, dynamic> json) =>
|
||||||
_$Emote7TVFileFromJson(json);
|
_$Emote7TVFileFromJson(json);
|
||||||
|
|
@ -234,36 +191,37 @@ class Emote {
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Emote.fromTwitch(EmoteTwitch emote, EmoteType type) => Emote(
|
factory Emote.fromTwitch(EmoteTwitch emote, EmoteType type) => Emote(
|
||||||
name: emote.name,
|
name: emote.name,
|
||||||
zeroWidth: false,
|
zeroWidth: false,
|
||||||
url:
|
url:
|
||||||
'https://static-cdn.jtvnw.net/emoticons/v2/${emote.id}/default/dark/3.0',
|
'https://static-cdn.jtvnw.net/emoticons/v2/${emote.id}/default/dark/3.0',
|
||||||
type: type,
|
type: type,
|
||||||
ownerId: emote.ownerId,
|
ownerId: emote.ownerId,
|
||||||
);
|
);
|
||||||
|
|
||||||
factory Emote.fromBTTV(EmoteBTTV emote, EmoteType type) => Emote(
|
factory Emote.fromBTTV(EmoteBTTV emote, EmoteType type) => Emote(
|
||||||
name: emote.code,
|
name: emote.code,
|
||||||
zeroWidth: zeroWidthEmotes.contains(emote.code),
|
zeroWidth: zeroWidthEmotes.contains(emote.code),
|
||||||
url: 'https://cdn.betterttv.net/emote/${emote.id}/3x',
|
url: 'https://cdn.betterttv.net/emote/${emote.id}/3x',
|
||||||
type: type,
|
type: type,
|
||||||
);
|
);
|
||||||
|
|
||||||
factory Emote.fromFFZ(EmoteFFZ emote, EmoteType type) => Emote(
|
factory Emote.fromFFZ(EmoteFFZ emote, EmoteType type) => Emote(
|
||||||
name: emote.name,
|
name: emote.name,
|
||||||
zeroWidth: false,
|
zeroWidth: false,
|
||||||
width: emote.width,
|
width: emote.width,
|
||||||
height: emote.height,
|
height: emote.height,
|
||||||
url: emote.animated?.url4x ??
|
url:
|
||||||
emote.animated?.url2x ??
|
emote.animated?.url4x ??
|
||||||
emote.animated?.url1x ??
|
emote.animated?.url2x ??
|
||||||
emote.urls.url4x ??
|
emote.animated?.url1x ??
|
||||||
emote.urls.url2x ??
|
emote.urls.url4x ??
|
||||||
emote.urls.url1x,
|
emote.urls.url2x ??
|
||||||
type: type,
|
emote.urls.url1x,
|
||||||
ownerDisplayName: emote.owner.displayName,
|
type: type,
|
||||||
ownerUsername: emote.owner.name,
|
ownerDisplayName: emote.owner.displayName,
|
||||||
);
|
ownerUsername: emote.owner.name,
|
||||||
|
);
|
||||||
|
|
||||||
factory Emote.from7TV(Emote7TV emote, EmoteType type) {
|
factory Emote.from7TV(Emote7TV emote, EmoteType type) {
|
||||||
final emoteData = emote.data;
|
final emoteData = emote.data;
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,14 @@ part of 'emotes.dart';
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
EmoteTwitch _$EmoteTwitchFromJson(Map<String, dynamic> json) => EmoteTwitch(
|
EmoteTwitch _$EmoteTwitchFromJson(Map<String, dynamic> json) => EmoteTwitch(
|
||||||
json['id'] as String,
|
json['id'] as String,
|
||||||
json['name'] as String,
|
json['name'] as String,
|
||||||
json['emote_type'] as String?,
|
json['emote_type'] as String?,
|
||||||
json['owner_id'] as String?,
|
json['owner_id'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
EmoteBTTV _$EmoteBTTVFromJson(Map<String, dynamic> json) => EmoteBTTV(
|
EmoteBTTV _$EmoteBTTVFromJson(Map<String, dynamic> json) =>
|
||||||
json['id'] as String,
|
EmoteBTTV(json['id'] as String, json['code'] as String);
|
||||||
json['code'] as String,
|
|
||||||
);
|
|
||||||
|
|
||||||
EmoteBTTVChannel _$EmoteBTTVChannelFromJson(Map<String, dynamic> json) =>
|
EmoteBTTVChannel _$EmoteBTTVChannelFromJson(Map<String, dynamic> json) =>
|
||||||
EmoteBTTVChannel(
|
EmoteBTTVChannel(
|
||||||
|
|
@ -29,97 +27,94 @@ EmoteBTTVChannel _$EmoteBTTVChannelFromJson(Map<String, dynamic> json) =>
|
||||||
);
|
);
|
||||||
|
|
||||||
RoomFFZ _$RoomFFZFromJson(Map<String, dynamic> json) => RoomFFZ(
|
RoomFFZ _$RoomFFZFromJson(Map<String, dynamic> json) => RoomFFZ(
|
||||||
(json['set'] as num).toInt(),
|
(json['set'] as num).toInt(),
|
||||||
json['vip_badge'] == null
|
json['vip_badge'] == null
|
||||||
? null
|
? null
|
||||||
: ImagesFFZ.fromJson(json['vip_badge'] as Map<String, dynamic>),
|
: ImagesFFZ.fromJson(json['vip_badge'] as Map<String, dynamic>),
|
||||||
json['mod_urls'] == null
|
json['mod_urls'] == null
|
||||||
? null
|
? null
|
||||||
: ImagesFFZ.fromJson(json['mod_urls'] as Map<String, dynamic>),
|
: ImagesFFZ.fromJson(json['mod_urls'] as Map<String, dynamic>),
|
||||||
);
|
);
|
||||||
|
|
||||||
ImagesFFZ _$ImagesFFZFromJson(Map<String, dynamic> json) => ImagesFFZ(
|
ImagesFFZ _$ImagesFFZFromJson(Map<String, dynamic> json) =>
|
||||||
json['1'] as String,
|
ImagesFFZ(json['1'] as String, json['2'] as String?, json['4'] as String?);
|
||||||
json['2'] as String?,
|
|
||||||
json['4'] as String?,
|
|
||||||
);
|
|
||||||
|
|
||||||
OwnerFFZ _$OwnerFFZFromJson(Map<String, dynamic> json) => OwnerFFZ(
|
OwnerFFZ _$OwnerFFZFromJson(Map<String, dynamic> json) => OwnerFFZ(
|
||||||
displayName: json['display_name'] as String,
|
displayName: json['display_name'] as String,
|
||||||
name: json['name'] as String,
|
name: json['name'] as String,
|
||||||
);
|
);
|
||||||
|
|
||||||
EmoteFFZ _$EmoteFFZFromJson(Map<String, dynamic> json) => EmoteFFZ(
|
EmoteFFZ _$EmoteFFZFromJson(Map<String, dynamic> json) => EmoteFFZ(
|
||||||
json['name'] as String,
|
json['name'] as String,
|
||||||
(json['height'] as num).toInt(),
|
(json['height'] as num).toInt(),
|
||||||
(json['width'] as num).toInt(),
|
(json['width'] as num).toInt(),
|
||||||
OwnerFFZ.fromJson(json['owner'] as Map<String, dynamic>),
|
OwnerFFZ.fromJson(json['owner'] as Map<String, dynamic>),
|
||||||
ImagesFFZ.fromJson(json['urls'] as Map<String, dynamic>),
|
ImagesFFZ.fromJson(json['urls'] as Map<String, dynamic>),
|
||||||
json['animated'] == null
|
json['animated'] == null
|
||||||
? null
|
? null
|
||||||
: ImagesFFZ.fromJson(json['animated'] as Map<String, dynamic>),
|
: ImagesFFZ.fromJson(json['animated'] as Map<String, dynamic>),
|
||||||
);
|
);
|
||||||
|
|
||||||
Emote7TV _$Emote7TVFromJson(Map<String, dynamic> json) => Emote7TV(
|
Emote7TV _$Emote7TVFromJson(Map<String, dynamic> json) => Emote7TV(
|
||||||
json['id'] as String,
|
json['id'] as String,
|
||||||
json['name'] as String,
|
json['name'] as String,
|
||||||
Emote7TVData.fromJson(json['data'] as Map<String, dynamic>),
|
Emote7TVData.fromJson(json['data'] as Map<String, dynamic>),
|
||||||
);
|
);
|
||||||
|
|
||||||
Owner7TV _$Owner7TVFromJson(Map<String, dynamic> json) => Owner7TV(
|
Owner7TV _$Owner7TVFromJson(Map<String, dynamic> json) => Owner7TV(
|
||||||
username: json['username'] as String,
|
username: json['username'] as String,
|
||||||
displayName: json['display_name'] as String,
|
displayName: json['display_name'] as String,
|
||||||
);
|
);
|
||||||
|
|
||||||
Emote7TVData _$Emote7TVDataFromJson(Map<String, dynamic> json) => Emote7TVData(
|
Emote7TVData _$Emote7TVDataFromJson(Map<String, dynamic> json) => Emote7TVData(
|
||||||
json['id'] as String,
|
json['id'] as String,
|
||||||
json['name'] as String,
|
json['name'] as String,
|
||||||
(json['flags'] as num).toInt(),
|
(json['flags'] as num).toInt(),
|
||||||
json['owner'] == null
|
json['owner'] == null
|
||||||
? null
|
? null
|
||||||
: Owner7TV.fromJson(json['owner'] as Map<String, dynamic>),
|
: Owner7TV.fromJson(json['owner'] as Map<String, dynamic>),
|
||||||
Emote7TVHost.fromJson(json['host'] as Map<String, dynamic>),
|
Emote7TVHost.fromJson(json['host'] as Map<String, dynamic>),
|
||||||
);
|
);
|
||||||
|
|
||||||
Emote7TVHost _$Emote7TVHostFromJson(Map<String, dynamic> json) => Emote7TVHost(
|
Emote7TVHost _$Emote7TVHostFromJson(Map<String, dynamic> json) => Emote7TVHost(
|
||||||
json['url'] as String,
|
json['url'] as String,
|
||||||
(json['files'] as List<dynamic>)
|
(json['files'] as List<dynamic>)
|
||||||
.map((e) => Emote7TVFile.fromJson(e as Map<String, dynamic>))
|
.map((e) => Emote7TVFile.fromJson(e as Map<String, dynamic>))
|
||||||
.toList(),
|
.toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Emote7TVFile _$Emote7TVFileFromJson(Map<String, dynamic> json) => Emote7TVFile(
|
Emote7TVFile _$Emote7TVFileFromJson(Map<String, dynamic> json) => Emote7TVFile(
|
||||||
json['name'] as String,
|
json['name'] as String,
|
||||||
(json['width'] as num).toInt(),
|
(json['width'] as num).toInt(),
|
||||||
(json['height'] as num).toInt(),
|
(json['height'] as num).toInt(),
|
||||||
json['format'] as String,
|
json['format'] as String,
|
||||||
);
|
);
|
||||||
|
|
||||||
Emote _$EmoteFromJson(Map<String, dynamic> json) => Emote(
|
Emote _$EmoteFromJson(Map<String, dynamic> json) => Emote(
|
||||||
name: json['name'] as String,
|
name: json['name'] as String,
|
||||||
realName: json['realName'] as String?,
|
realName: json['realName'] as String?,
|
||||||
width: (json['width'] as num?)?.toInt(),
|
width: (json['width'] as num?)?.toInt(),
|
||||||
height: (json['height'] as num?)?.toInt(),
|
height: (json['height'] as num?)?.toInt(),
|
||||||
zeroWidth: json['zeroWidth'] as bool,
|
zeroWidth: json['zeroWidth'] as bool,
|
||||||
url: json['url'] as String,
|
url: json['url'] as String,
|
||||||
type: $enumDecode(_$EmoteTypeEnumMap, json['type']),
|
type: $enumDecode(_$EmoteTypeEnumMap, json['type']),
|
||||||
ownerDisplayName: json['ownerDisplayName'] as String?,
|
ownerDisplayName: json['ownerDisplayName'] as String?,
|
||||||
ownerUsername: json['ownerUsername'] as String?,
|
ownerUsername: json['ownerUsername'] as String?,
|
||||||
ownerId: json['ownerId'] as String?,
|
ownerId: json['ownerId'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$EmoteToJson(Emote instance) => <String, dynamic>{
|
Map<String, dynamic> _$EmoteToJson(Emote instance) => <String, dynamic>{
|
||||||
'name': instance.name,
|
'name': instance.name,
|
||||||
'realName': instance.realName,
|
'realName': instance.realName,
|
||||||
'width': instance.width,
|
'width': instance.width,
|
||||||
'height': instance.height,
|
'height': instance.height,
|
||||||
'zeroWidth': instance.zeroWidth,
|
'zeroWidth': instance.zeroWidth,
|
||||||
'url': instance.url,
|
'url': instance.url,
|
||||||
'type': _$EmoteTypeEnumMap[instance.type]!,
|
'type': _$EmoteTypeEnumMap[instance.type]!,
|
||||||
'ownerDisplayName': instance.ownerDisplayName,
|
'ownerDisplayName': instance.ownerDisplayName,
|
||||||
'ownerUsername': instance.ownerUsername,
|
'ownerUsername': instance.ownerUsername,
|
||||||
'ownerId': instance.ownerId,
|
'ownerId': instance.ownerId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$EmoteTypeEnumMap = {
|
const _$EmoteTypeEnumMap = {
|
||||||
EmoteType.twitchBits: 'twitchBits',
|
EmoteType.twitchBits: 'twitchBits',
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,7 @@ class SevenTVEvent {
|
||||||
final int? t;
|
final int? t;
|
||||||
final SevenTVEventData d;
|
final SevenTVEventData d;
|
||||||
|
|
||||||
const SevenTVEvent({
|
const SevenTVEvent({required this.op, this.t, required this.d});
|
||||||
required this.op,
|
|
||||||
this.t,
|
|
||||||
required this.d,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory SevenTVEvent.fromJson(Map<String, dynamic> json) =>
|
factory SevenTVEvent.fromJson(Map<String, dynamic> json) =>
|
||||||
_$SevenTVEventFromJson(json);
|
_$SevenTVEventFromJson(json);
|
||||||
|
|
@ -29,11 +25,7 @@ class SevenTVEventData {
|
||||||
@JsonKey(includeToJson: false)
|
@JsonKey(includeToJson: false)
|
||||||
final SevenTVEventEmoteSetBody? body;
|
final SevenTVEventEmoteSetBody? body;
|
||||||
|
|
||||||
const SevenTVEventData({
|
const SevenTVEventData({required this.type, this.condition, this.body});
|
||||||
required this.type,
|
|
||||||
this.condition,
|
|
||||||
this.body,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory SevenTVEventData.fromJson(Map<String, dynamic> json) =>
|
factory SevenTVEventData.fromJson(Map<String, dynamic> json) =>
|
||||||
_$SevenTVEventDataFromJson(json);
|
_$SevenTVEventDataFromJson(json);
|
||||||
|
|
@ -46,10 +38,7 @@ class SevenTVEventUpdatedEmote {
|
||||||
final Emote7TV? value;
|
final Emote7TV? value;
|
||||||
final Emote7TV? oldValue;
|
final Emote7TV? oldValue;
|
||||||
|
|
||||||
const SevenTVEventUpdatedEmote({
|
const SevenTVEventUpdatedEmote({this.value, this.oldValue});
|
||||||
this.value,
|
|
||||||
this.oldValue,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory SevenTVEventUpdatedEmote.fromJson(Map<String, dynamic> json) =>
|
factory SevenTVEventUpdatedEmote.fromJson(Map<String, dynamic> json) =>
|
||||||
_$SevenTVEventUpdatedEmoteFromJson(json);
|
_$SevenTVEventUpdatedEmoteFromJson(json);
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,13 @@ part of 'events.dart';
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
SevenTVEvent _$SevenTVEventFromJson(Map<String, dynamic> json) => SevenTVEvent(
|
SevenTVEvent _$SevenTVEventFromJson(Map<String, dynamic> json) => SevenTVEvent(
|
||||||
op: (json['op'] as num).toInt(),
|
op: (json['op'] as num).toInt(),
|
||||||
t: (json['t'] as num?)?.toInt(),
|
t: (json['t'] as num?)?.toInt(),
|
||||||
d: SevenTVEventData.fromJson(json['d'] as Map<String, dynamic>),
|
d: SevenTVEventData.fromJson(json['d'] as Map<String, dynamic>),
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$SevenTVEventToJson(SevenTVEvent instance) =>
|
Map<String, dynamic> _$SevenTVEventToJson(SevenTVEvent instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{'op': instance.op, 't': ?instance.t, 'd': instance.d};
|
||||||
'op': instance.op,
|
|
||||||
if (instance.t case final value?) 't': value,
|
|
||||||
'd': instance.d,
|
|
||||||
};
|
|
||||||
|
|
||||||
SevenTVEventData _$SevenTVEventDataFromJson(Map<String, dynamic> json) =>
|
SevenTVEventData _$SevenTVEventDataFromJson(Map<String, dynamic> json) =>
|
||||||
SevenTVEventData(
|
SevenTVEventData(
|
||||||
|
|
@ -28,36 +24,32 @@ SevenTVEventData _$SevenTVEventDataFromJson(Map<String, dynamic> json) =>
|
||||||
body: json['body'] == null
|
body: json['body'] == null
|
||||||
? null
|
? null
|
||||||
: SevenTVEventEmoteSetBody.fromJson(
|
: SevenTVEventEmoteSetBody.fromJson(
|
||||||
json['body'] as Map<String, dynamic>),
|
json['body'] as Map<String, dynamic>,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$SevenTVEventDataToJson(SevenTVEventData instance) =>
|
Map<String, dynamic> _$SevenTVEventDataToJson(SevenTVEventData instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{'type': instance.type, 'condition': instance.condition};
|
||||||
'type': instance.type,
|
|
||||||
'condition': instance.condition,
|
|
||||||
};
|
|
||||||
|
|
||||||
SevenTVEventUpdatedEmote _$SevenTVEventUpdatedEmoteFromJson(
|
SevenTVEventUpdatedEmote _$SevenTVEventUpdatedEmoteFromJson(
|
||||||
Map<String, dynamic> json) =>
|
Map<String, dynamic> json,
|
||||||
SevenTVEventUpdatedEmote(
|
) => SevenTVEventUpdatedEmote(
|
||||||
value: json['value'] == null
|
value: json['value'] == null
|
||||||
? null
|
? null
|
||||||
: Emote7TV.fromJson(json['value'] as Map<String, dynamic>),
|
: Emote7TV.fromJson(json['value'] as Map<String, dynamic>),
|
||||||
oldValue: json['old_value'] == null
|
oldValue: json['old_value'] == null
|
||||||
? null
|
? null
|
||||||
: Emote7TV.fromJson(json['old_value'] as Map<String, dynamic>),
|
: Emote7TV.fromJson(json['old_value'] as Map<String, dynamic>),
|
||||||
);
|
);
|
||||||
|
|
||||||
SevenTVEventEmoteSetBody _$SevenTVEventEmoteSetBodyFromJson(
|
SevenTVEventEmoteSetBody _$SevenTVEventEmoteSetBodyFromJson(
|
||||||
Map<String, dynamic> json) =>
|
Map<String, dynamic> json,
|
||||||
SevenTVEventEmoteSetBody(
|
) => SevenTVEventEmoteSetBody(
|
||||||
actor: Owner7TV.fromJson(json['actor'] as Map<String, dynamic>),
|
actor: Owner7TV.fromJson(json['actor'] as Map<String, dynamic>),
|
||||||
pushed: (json['pushed'] as List<dynamic>?)
|
pushed: (json['pushed'] as List<dynamic>?)
|
||||||
?.map((e) =>
|
?.map((e) => SevenTVEventUpdatedEmote.fromJson(e as Map<String, dynamic>))
|
||||||
SevenTVEventUpdatedEmote.fromJson(e as Map<String, dynamic>))
|
.toList(),
|
||||||
.toList(),
|
pulled: (json['pulled'] as List<dynamic>?)
|
||||||
pulled: (json['pulled'] as List<dynamic>?)
|
?.map((e) => SevenTVEventUpdatedEmote.fromJson(e as Map<String, dynamic>))
|
||||||
?.map((e) =>
|
.toList(),
|
||||||
SevenTVEventUpdatedEmote.fromJson(e as Map<String, dynamic>))
|
);
|
||||||
.toList(),
|
|
||||||
);
|
|
||||||
|
|
|
||||||
39
lib/models/followed_channel.dart
Normal file
39
lib/models/followed_channel.dart
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
part 'followed_channel.g.dart';
|
||||||
|
|
||||||
|
// Object for followed Twitch channels.
|
||||||
|
@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
|
||||||
|
class FollowedChannel {
|
||||||
|
final String broadcasterId;
|
||||||
|
final String broadcasterLogin;
|
||||||
|
final String broadcasterName;
|
||||||
|
final DateTime followedAt;
|
||||||
|
|
||||||
|
const FollowedChannel({
|
||||||
|
required this.broadcasterId,
|
||||||
|
required this.broadcasterLogin,
|
||||||
|
required this.broadcasterName,
|
||||||
|
required this.followedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory FollowedChannel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$FollowedChannelFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response object for followed channels API call.
|
||||||
|
@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
|
||||||
|
class FollowedChannels {
|
||||||
|
final List<FollowedChannel> data;
|
||||||
|
final Map<String, String> pagination;
|
||||||
|
final int total;
|
||||||
|
|
||||||
|
const FollowedChannels({
|
||||||
|
required this.data,
|
||||||
|
required this.pagination,
|
||||||
|
required this.total,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory FollowedChannels.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$FollowedChannelsFromJson(json);
|
||||||
|
}
|
||||||
24
lib/models/followed_channel.g.dart
Normal file
24
lib/models/followed_channel.g.dart
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'followed_channel.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
FollowedChannel _$FollowedChannelFromJson(Map<String, dynamic> json) =>
|
||||||
|
FollowedChannel(
|
||||||
|
broadcasterId: json['broadcaster_id'] as String,
|
||||||
|
broadcasterLogin: json['broadcaster_login'] as String,
|
||||||
|
broadcasterName: json['broadcaster_name'] as String,
|
||||||
|
followedAt: DateTime.parse(json['followed_at'] as String),
|
||||||
|
);
|
||||||
|
|
||||||
|
FollowedChannels _$FollowedChannelsFromJson(Map<String, dynamic> json) =>
|
||||||
|
FollowedChannels(
|
||||||
|
data: (json['data'] as List<dynamic>)
|
||||||
|
.map((e) => FollowedChannel.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
pagination: Map<String, String>.from(json['pagination'] as Map),
|
||||||
|
total: (json['total'] as num).toInt(),
|
||||||
|
);
|
||||||
File diff suppressed because it is too large
Load diff
65
lib/models/playback_access_token.dart
Normal file
65
lib/models/playback_access_token.dart
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
class PlaybackAccessToken {
|
||||||
|
final String value;
|
||||||
|
final String signature;
|
||||||
|
|
||||||
|
/// Whether Twitch will suppress ads for this session (Turbo, sub, etc.).
|
||||||
|
/// Defaults to false when the token payload can't be parsed.
|
||||||
|
final bool hideAds;
|
||||||
|
|
||||||
|
/// Quality group names (e.g. "1080p60") that exist for this stream but are
|
||||||
|
/// restricted to subscribers, so they won't appear in the HLS playlist.
|
||||||
|
final List<String> restrictedQualities;
|
||||||
|
|
||||||
|
const PlaybackAccessToken({
|
||||||
|
required this.value,
|
||||||
|
required this.signature,
|
||||||
|
this.hideAds = false,
|
||||||
|
this.restrictedQualities = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Names in restricted_bitrates that aren't real video qualities
|
||||||
|
/// (matches streamlink's filter).
|
||||||
|
static final _nonQualityNameRe = RegExp(r'^((.+_)?archives|live|chunked)');
|
||||||
|
|
||||||
|
factory PlaybackAccessToken.fromGqlResponse(Map<String, dynamic> json) {
|
||||||
|
final data =
|
||||||
|
(json['data'] as Map<String, dynamic>?)?['streamPlaybackAccessToken']
|
||||||
|
as Map<String, dynamic>?;
|
||||||
|
if (data == null) {
|
||||||
|
throw const FormatException(
|
||||||
|
'streamPlaybackAccessToken missing from GQL response',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final value = data['value'] as String;
|
||||||
|
var hideAds = false;
|
||||||
|
var restricted = const <String>[];
|
||||||
|
try {
|
||||||
|
final payload = jsonDecode(value) as Map<String, dynamic>;
|
||||||
|
hideAds = payload['hide_ads'] as bool? ?? false;
|
||||||
|
final bitrates =
|
||||||
|
(payload['chansub'] as Map<String, dynamic>?)?['restricted_bitrates']
|
||||||
|
as List<dynamic>?;
|
||||||
|
if (bitrates != null) {
|
||||||
|
restricted = bitrates
|
||||||
|
.whereType<String>()
|
||||||
|
.where((name) => !_nonQualityNameRe.hasMatch(name))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Token payload format is Twitch-internal — never fail playback over it.
|
||||||
|
debugPrint('PlaybackAccessToken: failed to parse token payload: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
return PlaybackAccessToken(
|
||||||
|
value: value,
|
||||||
|
signature: data['signature'] as String,
|
||||||
|
hideAds: hideAds,
|
||||||
|
restrictedQualities: restricted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,5 @@ SharedChatSession _$SharedChatSessionFromJson(Map<String, dynamic> json) =>
|
||||||
updatedAt: json['updated_at'] as String,
|
updatedAt: json['updated_at'] as String,
|
||||||
);
|
);
|
||||||
|
|
||||||
Participant _$ParticipantFromJson(Map<String, dynamic> json) => Participant(
|
Participant _$ParticipantFromJson(Map<String, dynamic> json) =>
|
||||||
broadcasterId: json['broadcaster_id'] as String,
|
Participant(broadcasterId: json['broadcaster_id'] as String);
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,14 @@ class StreamTwitch {
|
||||||
final String startedAt;
|
final String startedAt;
|
||||||
final String thumbnailUrl;
|
final String thumbnailUrl;
|
||||||
|
|
||||||
|
/// ISO 639-1 broadcast language from Helix (`de`, `en`, …). Empty when unknown.
|
||||||
|
@JsonKey(defaultValue: '')
|
||||||
|
final String language;
|
||||||
|
|
||||||
|
/// Freeform tags from Helix (e.g. `Deutsch`, `English`).
|
||||||
|
@JsonKey(defaultValue: <String>[])
|
||||||
|
final List<String> tags;
|
||||||
|
|
||||||
const StreamTwitch(
|
const StreamTwitch(
|
||||||
this.userId,
|
this.userId,
|
||||||
this.userLogin,
|
this.userLogin,
|
||||||
|
|
@ -24,8 +32,10 @@ class StreamTwitch {
|
||||||
this.title,
|
this.title,
|
||||||
this.viewerCount,
|
this.viewerCount,
|
||||||
this.startedAt,
|
this.startedAt,
|
||||||
this.thumbnailUrl,
|
this.thumbnailUrl, [
|
||||||
);
|
this.language = '',
|
||||||
|
this.tags = const [],
|
||||||
|
]);
|
||||||
|
|
||||||
factory StreamTwitch.fromJson(Map<String, dynamic> json) =>
|
factory StreamTwitch.fromJson(Map<String, dynamic> json) =>
|
||||||
_$StreamTwitchFromJson(json);
|
_$StreamTwitchFromJson(json);
|
||||||
|
|
@ -36,11 +46,22 @@ class StreamsTwitch {
|
||||||
final List<StreamTwitch> data;
|
final List<StreamTwitch> data;
|
||||||
final Map<String, String> pagination;
|
final Map<String, String> pagination;
|
||||||
|
|
||||||
const StreamsTwitch(
|
const StreamsTwitch(this.data, this.pagination);
|
||||||
this.data,
|
|
||||||
this.pagination,
|
|
||||||
);
|
|
||||||
|
|
||||||
factory StreamsTwitch.fromJson(Map<String, dynamic> json) =>
|
factory StreamsTwitch.fromJson(Map<String, dynamic> json) {
|
||||||
_$StreamsTwitchFromJson(json);
|
final rawPagination = json['pagination'];
|
||||||
|
final pagination = <String, String>{};
|
||||||
|
if (rawPagination is Map) {
|
||||||
|
final cursor = rawPagination['cursor'];
|
||||||
|
if (cursor is String && cursor.isNotEmpty) {
|
||||||
|
pagination['cursor'] = cursor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return StreamsTwitch(
|
||||||
|
(json['data'] as List<dynamic>)
|
||||||
|
.map((e) => StreamTwitch.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
pagination,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,18 @@ part of 'stream.dart';
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
StreamTwitch _$StreamTwitchFromJson(Map<String, dynamic> json) => StreamTwitch(
|
StreamTwitch _$StreamTwitchFromJson(Map<String, dynamic> json) => StreamTwitch(
|
||||||
json['user_id'] as String,
|
json['user_id'] as String,
|
||||||
json['user_login'] as String,
|
json['user_login'] as String,
|
||||||
json['user_name'] as String,
|
json['user_name'] as String,
|
||||||
json['game_id'] as String,
|
json['game_id'] as String,
|
||||||
json['game_name'] as String,
|
json['game_name'] as String,
|
||||||
json['title'] as String,
|
json['title'] as String,
|
||||||
(json['viewer_count'] as num).toInt(),
|
(json['viewer_count'] as num).toInt(),
|
||||||
json['started_at'] as String,
|
json['started_at'] as String,
|
||||||
json['thumbnail_url'] as String,
|
json['thumbnail_url'] as String,
|
||||||
);
|
json['language'] as String? ?? '',
|
||||||
|
(json['tags'] as List<dynamic>?)?.map((e) => e as String).toList() ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
StreamsTwitch _$StreamsTwitchFromJson(Map<String, dynamic> json) =>
|
StreamsTwitch _$StreamsTwitchFromJson(Map<String, dynamic> json) =>
|
||||||
StreamsTwitch(
|
StreamsTwitch(
|
||||||
|
|
|
||||||
50
lib/models/stream_languages.dart
Normal file
50
lib/models/stream_languages.dart
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
/// ISO 639-1 codes offered in Settings for stream language filtering.
|
||||||
|
///
|
||||||
|
/// Stored lowercase (Helix). GQL `Language` enums are the uppercase form.
|
||||||
|
class StreamLanguages {
|
||||||
|
StreamLanguages._();
|
||||||
|
|
||||||
|
static const all = <({String code, String label})>[
|
||||||
|
(code: 'de', label: 'Deutsch'),
|
||||||
|
(code: 'en', label: 'English'),
|
||||||
|
(code: 'es', label: 'Español'),
|
||||||
|
(code: 'fr', label: 'Français'),
|
||||||
|
(code: 'it', label: 'Italiano'),
|
||||||
|
(code: 'pt', label: 'Português'),
|
||||||
|
(code: 'pl', label: 'Polski'),
|
||||||
|
(code: 'ru', label: 'Русский'),
|
||||||
|
(code: 'tr', label: 'Türkçe'),
|
||||||
|
(code: 'nl', label: 'Nederlands'),
|
||||||
|
(code: 'sv', label: 'Svenska'),
|
||||||
|
(code: 'no', label: 'Norsk'),
|
||||||
|
(code: 'da', label: 'Dansk'),
|
||||||
|
(code: 'fi', label: 'Suomi'),
|
||||||
|
(code: 'cs', label: 'Čeština'),
|
||||||
|
(code: 'hu', label: 'Magyar'),
|
||||||
|
(code: 'ro', label: 'Română'),
|
||||||
|
(code: 'uk', label: 'Українська'),
|
||||||
|
(code: 'ja', label: '日本語'),
|
||||||
|
(code: 'ko', label: '한국어'),
|
||||||
|
(code: 'zh', label: '中文'),
|
||||||
|
(code: 'ar', label: 'العربية'),
|
||||||
|
(code: 'th', label: 'ไทย'),
|
||||||
|
(code: 'vi', label: 'Tiếng Việt'),
|
||||||
|
(code: 'id', label: 'Bahasa Indonesia'),
|
||||||
|
(code: 'hi', label: 'हिन्दी'),
|
||||||
|
(code: 'other', label: 'Other'),
|
||||||
|
];
|
||||||
|
|
||||||
|
static String labelFor(String code) {
|
||||||
|
for (final entry in all) {
|
||||||
|
if (entry.code == code) return entry.label;
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helix / settings codes → GQL `Language` enum values.
|
||||||
|
static List<String> toGqlEnums(Iterable<String> codes) => codes
|
||||||
|
.map((c) => c.trim().toLowerCase())
|
||||||
|
.where((c) => c.isNotEmpty)
|
||||||
|
.map((c) => c.toUpperCase())
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
@ -10,12 +10,7 @@ class UserTwitch {
|
||||||
final String displayName;
|
final String displayName;
|
||||||
final String profileImageUrl;
|
final String profileImageUrl;
|
||||||
|
|
||||||
const UserTwitch(
|
const UserTwitch(this.id, this.login, this.displayName, this.profileImageUrl);
|
||||||
this.id,
|
|
||||||
this.login,
|
|
||||||
this.displayName,
|
|
||||||
this.profileImageUrl,
|
|
||||||
);
|
|
||||||
|
|
||||||
factory UserTwitch.fromJson(Map<String, dynamic> json) =>
|
factory UserTwitch.fromJson(Map<String, dynamic> json) =>
|
||||||
_$UserTwitchFromJson(json);
|
_$UserTwitchFromJson(json);
|
||||||
|
|
@ -27,11 +22,7 @@ class UserBlockedTwitch {
|
||||||
final String userLogin;
|
final String userLogin;
|
||||||
final String displayName;
|
final String displayName;
|
||||||
|
|
||||||
const UserBlockedTwitch(
|
const UserBlockedTwitch(this.userId, this.userLogin, this.displayName);
|
||||||
this.userId,
|
|
||||||
this.userLogin,
|
|
||||||
this.displayName,
|
|
||||||
);
|
|
||||||
|
|
||||||
factory UserBlockedTwitch.fromJson(Map<String, dynamic> json) =>
|
factory UserBlockedTwitch.fromJson(Map<String, dynamic> json) =>
|
||||||
_$UserBlockedTwitchFromJson(json);
|
_$UserBlockedTwitchFromJson(json);
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,11 @@ part of 'user.dart';
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
UserTwitch _$UserTwitchFromJson(Map<String, dynamic> json) => UserTwitch(
|
UserTwitch _$UserTwitchFromJson(Map<String, dynamic> json) => UserTwitch(
|
||||||
json['id'] as String,
|
json['id'] as String,
|
||||||
json['login'] as String,
|
json['login'] as String,
|
||||||
json['display_name'] as String,
|
json['display_name'] as String,
|
||||||
json['profile_image_url'] as String,
|
json['profile_image_url'] as String,
|
||||||
);
|
);
|
||||||
|
|
||||||
UserBlockedTwitch _$UserBlockedTwitchFromJson(Map<String, dynamic> json) =>
|
UserBlockedTwitch _$UserBlockedTwitchFromJson(Map<String, dynamic> json) =>
|
||||||
UserBlockedTwitch(
|
UserBlockedTwitch(
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,18 +1,42 @@
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||||
import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart';
|
import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart';
|
||||||
import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart';
|
import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart';
|
||||||
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
||||||
|
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
|
||||||
import 'package:frosty/screens/channel/chat/widgets/chat_bottom_bar.dart';
|
import 'package:frosty/screens/channel/chat/widgets/chat_bottom_bar.dart';
|
||||||
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
|
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
|
||||||
import 'package:frosty/widgets/page_view.dart';
|
import 'package:frosty/utils.dart';
|
||||||
|
import 'package:frosty/utils/context_extensions.dart';
|
||||||
|
import 'package:frosty/widgets/frosty_page_view.dart';
|
||||||
|
import 'package:frosty/widgets/frosty_scrollbar.dart';
|
||||||
|
|
||||||
|
/// ~2x default to keep richer message widgets (emotes, badges, replies) built
|
||||||
|
/// ahead of fast scroll-back without holding the entire history.
|
||||||
|
const _chatCacheExtent = ScrollCacheExtent.pixels(500.0);
|
||||||
|
|
||||||
class Chat extends StatelessWidget {
|
class Chat extends StatelessWidget {
|
||||||
final ChatStore chatStore;
|
final ChatStore chatStore;
|
||||||
|
final EdgeInsetsGeometry? listPadding;
|
||||||
|
|
||||||
const Chat({super.key, required this.chatStore});
|
/// Callback to add a new chat tab.
|
||||||
|
/// Passes this to ChatBottomBar for the ChatDetails menu.
|
||||||
|
final VoidCallback onAddChat;
|
||||||
|
|
||||||
|
/// When set, the widget operates in merged mode: messages come from all tabs
|
||||||
|
/// and the active tab determines the send target.
|
||||||
|
final ChatTabsStore? chatTabsStore;
|
||||||
|
|
||||||
|
const Chat({
|
||||||
|
super.key,
|
||||||
|
required this.chatStore,
|
||||||
|
this.listPadding,
|
||||||
|
required this.onAddChat,
|
||||||
|
this.chatTabsStore,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get _isMerged => chatTabsStore != null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -21,158 +45,335 @@ class Chat extends StatelessWidget {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: GestureDetector(
|
child: Stack(
|
||||||
onTap: () {
|
alignment: AlignmentDirectional.bottomCenter,
|
||||||
if (chatStore.assetsStore.showEmoteMenu) {
|
children: [
|
||||||
chatStore.assetsStore.showEmoteMenu = false;
|
// Wrap only the message list with GestureDetector
|
||||||
} else if (chatStore.textFieldFocusNode.hasFocus) {
|
// so taps on ChatBottomBar don't trigger unfocus
|
||||||
chatStore.textFieldFocusNode.unfocus();
|
GestureDetector(
|
||||||
}
|
behavior: HitTestBehavior.opaque,
|
||||||
},
|
onTap: () {
|
||||||
child: Stack(
|
if (chatStore.assetsStore.showEmoteMenu) {
|
||||||
alignment: AlignmentDirectional.bottomCenter,
|
chatStore.assetsStore.showEmoteMenu = false;
|
||||||
children: [
|
} else if (chatStore.textFieldFocusNode.hasFocus) {
|
||||||
MediaQuery(
|
chatStore.unfocusInput();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: MediaQuery(
|
||||||
data: MediaQuery.of(context).copyWith(
|
data: MediaQuery.of(context).copyWith(
|
||||||
textScaler:
|
textScaler: chatStore.settings.messageScale.textScaler,
|
||||||
TextScaler.linear(chatStore.settings.messageScale),
|
|
||||||
),
|
),
|
||||||
child: DefaultTextStyle(
|
child: DefaultTextStyle(
|
||||||
style: DefaultTextStyle.of(context)
|
style: context.defaultTextStyle.copyWith(
|
||||||
.style
|
fontSize: chatStore.settings.fontSize,
|
||||||
.copyWith(fontSize: chatStore.settings.fontSize),
|
|
||||||
child: Scrollbar(
|
|
||||||
controller: chatStore.scrollController,
|
|
||||||
child: Observer(
|
|
||||||
builder: (context) {
|
|
||||||
return ListView.builder(
|
|
||||||
reverse: true,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
addAutomaticKeepAlives: false,
|
|
||||||
controller: chatStore.scrollController,
|
|
||||||
itemCount: chatStore.renderMessages.length,
|
|
||||||
itemBuilder: (context, index) => ChatMessage(
|
|
||||||
ircMessage: chatStore.renderMessages[
|
|
||||||
chatStore.renderMessages.length -
|
|
||||||
1 -
|
|
||||||
index],
|
|
||||||
chatStore: chatStore,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
child: Builder(
|
||||||
),
|
builder: (context) {
|
||||||
Padding(
|
// Don't add bottom padding in horizontal landscape
|
||||||
padding: const EdgeInsets.all(4),
|
// (immersive mode with home indicator on side).
|
||||||
child: Observer(
|
// landscapeForceVerticalChat uses portrait layout
|
||||||
builder: (_) => AnimatedSwitcher(
|
// with normal system UI, so still needs padding.
|
||||||
duration: const Duration(milliseconds: 200),
|
final isHorizontalLandscape =
|
||||||
switchInCurve: Curves.easeOut,
|
context.isLandscape &&
|
||||||
switchOutCurve: Curves.easeIn,
|
!chatStore.settings.landscapeForceVerticalChat;
|
||||||
child: chatStore.autoScroll
|
final bottomPadding =
|
||||||
? null
|
chatStore.assetsStore.showEmoteMenu ||
|
||||||
: ElevatedButton.icon(
|
isHorizontalLandscape
|
||||||
onPressed: chatStore.resumeScroll,
|
? 0.0
|
||||||
icon:
|
: MediaQuery.of(context).padding.bottom;
|
||||||
const Icon(Icons.arrow_downward_rounded),
|
|
||||||
label: Text(
|
|
||||||
chatStore.messageBuffer.isNotEmpty
|
|
||||||
? '${chatStore.messageBuffer.length} new ${chatStore.messageBuffer.length == 1 ? 'message' : 'messages'}'
|
|
||||||
: 'Resume scroll',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontFeatures: [
|
|
||||||
FontFeature.tabularFigures(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (chatStore.settings.showBottomBar)
|
|
||||||
ChatBottomBar(chatStore: chatStore),
|
|
||||||
PopScope(
|
|
||||||
canPop: Platform.isIOS,
|
|
||||||
onPopInvokedWithResult: (didPop, _) {
|
|
||||||
if (didPop) return;
|
|
||||||
|
|
||||||
// If pressing the back button on Android while the emote menu
|
final scrollController = _isMerged
|
||||||
// is open, close it instead of going back to the streams list.
|
? chatTabsStore!.mergedScrollController
|
||||||
if (chatStore.assetsStore.showEmoteMenu) {
|
: chatStore.scrollController;
|
||||||
chatStore.assetsStore.showEmoteMenu = false;
|
|
||||||
} else {
|
// Freeze the message list while a finger rests on a
|
||||||
Navigator.of(context).pop();
|
// message so a long-press target can't scroll out
|
||||||
}
|
// from under it (which would dispose its recognizer
|
||||||
},
|
// mid-press). The store resumes flowing as soon as
|
||||||
child: AnimatedContainer(
|
// the pointer moves like a scroll, so scrolling is
|
||||||
curve: Curves.ease,
|
// never interrupted — only a stationary hold pauses.
|
||||||
duration: const Duration(milliseconds: 200),
|
void onDown(PointerDownEvent e) => _isMerged
|
||||||
height: chatStore.assetsStore.showEmoteMenu
|
? chatTabsStore!.onMessageListPointerDown(
|
||||||
? MediaQuery.of(context).size.height /
|
e.pointer,
|
||||||
(MediaQuery.of(context).orientation ==
|
e.position,
|
||||||
Orientation.portrait
|
)
|
||||||
? 3
|
: chatStore.onMessageListPointerDown(
|
||||||
: 2)
|
e.pointer,
|
||||||
: 0,
|
e.position,
|
||||||
child: AnimatedSwitcher(
|
);
|
||||||
duration: const Duration(milliseconds: 100),
|
void onMove(PointerMoveEvent e) => _isMerged
|
||||||
switchInCurve: Curves.easeOut,
|
? chatTabsStore!.onMessageListPointerMove(
|
||||||
switchOutCurve: Curves.easeIn,
|
e.pointer,
|
||||||
child: chatStore.assetsStore.showEmoteMenu
|
e.position,
|
||||||
? Column(
|
)
|
||||||
children: [
|
: chatStore.onMessageListPointerMove(
|
||||||
const Divider(),
|
e.pointer,
|
||||||
Expanded(
|
e.position,
|
||||||
child: FrostyPageView(
|
);
|
||||||
headers: [
|
void onUp(int pointer) => _isMerged
|
||||||
'Recent',
|
? chatTabsStore!.onMessageListPointerUp(pointer)
|
||||||
if (chatStore.settings.showTwitchEmotes)
|
: chatStore.onMessageListPointerUp(pointer);
|
||||||
'Twitch',
|
|
||||||
if (chatStore.settings.show7TVEmotes) '7TV',
|
return Listener(
|
||||||
if (chatStore.settings.showBTTVEmotes) 'BTTV',
|
onPointerDown: onDown,
|
||||||
if (chatStore.settings.showFFZEmotes) 'FFZ',
|
onPointerMove: onMove,
|
||||||
],
|
onPointerUp: (e) => onUp(e.pointer),
|
||||||
children: [
|
onPointerCancel: (e) => onUp(e.pointer),
|
||||||
RecentEmotesPanel(
|
child: FrostyScrollbar(
|
||||||
chatStore: chatStore,
|
controller: scrollController,
|
||||||
),
|
padding: EdgeInsets.only(
|
||||||
if (chatStore.settings.showTwitchEmotes)
|
top: MediaQuery.of(context).padding.top,
|
||||||
EmoteMenuPanel(
|
bottom:
|
||||||
chatStore: chatStore,
|
chatStore.bottomBarHeight + bottomPadding,
|
||||||
twitchEmotes: chatStore
|
),
|
||||||
.assetsStore.userEmoteSectionToEmotes,
|
child: Observer(
|
||||||
),
|
builder: (context) {
|
||||||
...[
|
return _isMerged
|
||||||
if (chatStore.settings.show7TVEmotes)
|
? _buildMergedList(
|
||||||
chatStore.assetsStore.sevenTVEmotes,
|
scrollController,
|
||||||
if (chatStore.settings.showBTTVEmotes)
|
bottomPadding,
|
||||||
chatStore.assetsStore.bttvEmotes,
|
)
|
||||||
if (chatStore.settings.showFFZEmotes)
|
: _buildNormalList(
|
||||||
chatStore.assetsStore.ffzEmotes,
|
scrollController,
|
||||||
].map(
|
bottomPadding,
|
||||||
(emotes) => EmoteMenuPanel(
|
);
|
||||||
chatStore: chatStore,
|
},
|
||||||
emotes: emotes,
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
],
|
},
|
||||||
)
|
),
|
||||||
: null,
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: ChatBottomBar(
|
||||||
|
chatStore: chatStore,
|
||||||
|
onAddChat: onAddChat,
|
||||||
|
channelDisplayName: _isMerged
|
||||||
|
? getReadableName(
|
||||||
|
chatTabsStore!.activeTab.displayName,
|
||||||
|
chatTabsStore!.activeTab.channelLogin,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildResumeScrollButton(),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
_buildEmoteMenu(context),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildNormalList(
|
||||||
|
ScrollController scrollController,
|
||||||
|
double bottomPadding,
|
||||||
|
) {
|
||||||
|
return ListView.builder(
|
||||||
|
reverse: true,
|
||||||
|
padding: (listPadding ?? EdgeInsets.zero).add(
|
||||||
|
EdgeInsets.only(bottom: chatStore.bottomBarHeight + bottomPadding),
|
||||||
|
),
|
||||||
|
addAutomaticKeepAlives: false,
|
||||||
|
scrollCacheExtent: _chatCacheExtent,
|
||||||
|
controller: scrollController,
|
||||||
|
itemCount: chatStore.renderMessages.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final ircMessage = chatStore
|
||||||
|
.renderMessages[chatStore.renderMessages.length - 1 - index];
|
||||||
|
// Key by message identity so the list reconciles by message rather
|
||||||
|
// than by slot. This keeps each message's element (and its gesture
|
||||||
|
// recognizers) bound to the same message when new messages arrive
|
||||||
|
// mid-interaction, fixing long-press landing on the wrong message.
|
||||||
|
return ChatMessage(
|
||||||
|
key: ObjectKey(ircMessage),
|
||||||
|
ircMessage: ircMessage,
|
||||||
|
chatStore: chatStore,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMergedList(
|
||||||
|
ScrollController scrollController,
|
||||||
|
double bottomPadding,
|
||||||
|
) {
|
||||||
|
final mergedMessages = chatTabsStore!.mergedMessages;
|
||||||
|
final channelIdToUserTwitch = chatTabsStore!.mergedChannelIdToUserTwitch;
|
||||||
|
final currentChannelId = chatTabsStore!.activeTab.channelId;
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
reverse: true,
|
||||||
|
padding: (listPadding ?? EdgeInsets.zero).add(
|
||||||
|
EdgeInsets.only(bottom: chatStore.bottomBarHeight + bottomPadding),
|
||||||
|
),
|
||||||
|
addAutomaticKeepAlives: false,
|
||||||
|
scrollCacheExtent: _chatCacheExtent,
|
||||||
|
controller: scrollController,
|
||||||
|
itemCount: mergedMessages.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final merged = mergedMessages[mergedMessages.length - 1 - index];
|
||||||
|
return ChatMessage(
|
||||||
|
key: ObjectKey(merged.ircMessage),
|
||||||
|
ircMessage: merged.ircMessage,
|
||||||
|
chatStore: merged.chatStore,
|
||||||
|
inputChatStore: chatStore,
|
||||||
|
onActivateSourceTab: () {
|
||||||
|
final tabs = chatTabsStore!.tabs;
|
||||||
|
final idx = tabs.indexWhere(
|
||||||
|
(t) => t.channelId == merged.chatStore.channelId,
|
||||||
|
);
|
||||||
|
if (idx != -1 && idx != chatTabsStore!.activeTabIndex) {
|
||||||
|
chatTabsStore!.setActiveTab(idx, silent: true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
overrideChannelIdToUserTwitch: channelIdToUserTwitch,
|
||||||
|
overrideCurrentChannelId: currentChannelId,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildResumeScrollButton() {
|
||||||
|
return Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final isHorizontalLandscape =
|
||||||
|
context.isLandscape &&
|
||||||
|
!chatStore.settings.landscapeForceVerticalChat;
|
||||||
|
return AnimatedPadding(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: 4,
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
bottom:
|
||||||
|
chatStore.bottomBarHeight +
|
||||||
|
(chatStore.assetsStore.showEmoteMenu || isHorizontalLandscape
|
||||||
|
? 0
|
||||||
|
: MediaQuery.of(context).padding.bottom),
|
||||||
|
),
|
||||||
|
child: Observer(
|
||||||
|
builder: (_) {
|
||||||
|
final isAutoScrolling = _isMerged
|
||||||
|
? chatTabsStore!.mergedAutoScroll
|
||||||
|
: chatStore.autoScroll;
|
||||||
|
final bufferCount = _isMerged
|
||||||
|
? chatTabsStore!.mergedBufferCount
|
||||||
|
: chatStore.messageBuffer.length;
|
||||||
|
final onResume = _isMerged
|
||||||
|
? chatTabsStore!.resumeMergedScroll
|
||||||
|
: chatStore.resumeScroll;
|
||||||
|
|
||||||
|
return AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
switchInCurve: Curves.easeOut,
|
||||||
|
switchOutCurve: Curves.easeIn,
|
||||||
|
child: isAutoScrolling
|
||||||
|
? null
|
||||||
|
: ElevatedButton.icon(
|
||||||
|
onPressed: onResume,
|
||||||
|
icon: const Icon(Icons.arrow_downward_rounded),
|
||||||
|
label: Text(
|
||||||
|
bufferCount > 0
|
||||||
|
? '$bufferCount new ${bufferCount == 1 ? 'message' : 'messages'}'
|
||||||
|
: 'Resume scroll',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFeatures: [FontFeature.tabularFigures()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmoteMenu(BuildContext context) {
|
||||||
|
final menuHeight = context.screenHeight / (context.isPortrait ? 3 : 2);
|
||||||
|
return AnimatedContainer(
|
||||||
|
curve: Curves.ease,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
height: chatStore.assetsStore.showEmoteMenu ? menuHeight : 0,
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 100),
|
||||||
|
switchInCurve: Curves.easeOut,
|
||||||
|
switchOutCurve: Curves.easeIn,
|
||||||
|
child: chatStore.assetsStore.showEmoteMenu
|
||||||
|
? ClipRect(
|
||||||
|
// Lay the content out at its full target height regardless of
|
||||||
|
// the animated container's current height, then clip — the
|
||||||
|
// Column would otherwise overflow (RenderFlex error) as the
|
||||||
|
// height tweens toward zero and can no longer fit the Divider.
|
||||||
|
child: OverflowBox(
|
||||||
|
minHeight: 0,
|
||||||
|
maxHeight: menuHeight,
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const Divider(),
|
||||||
|
Expanded(
|
||||||
|
child: FrostyPageView(
|
||||||
|
headers: [
|
||||||
|
'Recent',
|
||||||
|
if (chatStore.settings.showTwitchEmotes) 'Twitch',
|
||||||
|
if (chatStore.settings.show7TVEmotes) '7TV',
|
||||||
|
if (chatStore.settings.showBTTVEmotes) 'BTTV',
|
||||||
|
if (chatStore.settings.showFFZEmotes) 'FFZ',
|
||||||
|
],
|
||||||
|
tabActions: {
|
||||||
|
if (chatStore.assetsStore.recentEmotes.isNotEmpty)
|
||||||
|
0: IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
chatStore.assetsStore.recentEmotes.clear();
|
||||||
|
chatStore.updateNotification(
|
||||||
|
'Recent emotes cleared',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.delete_outline_rounded),
|
||||||
|
tooltip: 'Clear recent emotes',
|
||||||
|
iconSize: 20,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
RecentEmotesPanel(chatStore: chatStore),
|
||||||
|
if (chatStore.settings.showTwitchEmotes)
|
||||||
|
EmoteMenuPanel(
|
||||||
|
chatStore: chatStore,
|
||||||
|
twitchEmotes: chatStore
|
||||||
|
.assetsStore
|
||||||
|
.userEmoteSectionToEmotes,
|
||||||
|
),
|
||||||
|
...[
|
||||||
|
if (chatStore.settings.show7TVEmotes)
|
||||||
|
chatStore.assetsStore.sevenTVEmotes,
|
||||||
|
if (chatStore.settings.showBTTVEmotes)
|
||||||
|
chatStore.assetsStore.bttvEmotes,
|
||||||
|
if (chatStore.settings.showFFZEmotes)
|
||||||
|
chatStore.assetsStore.ffzEmotes,
|
||||||
|
].map(
|
||||||
|
(emotes) => EmoteMenuPanel(
|
||||||
|
chatStore: chatStore,
|
||||||
|
emotes: emotes,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||||
|
import 'package:frosty/constants.dart';
|
||||||
import 'package:frosty/main.dart';
|
import 'package:frosty/main.dart';
|
||||||
|
import 'package:frosty/models/irc.dart';
|
||||||
import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
|
import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
|
||||||
import 'package:frosty/screens/channel/chat/details/chat_modes.dart';
|
import 'package:frosty/screens/channel/chat/details/chat_modes.dart';
|
||||||
import 'package:frosty/screens/channel/chat/details/chat_users_list.dart';
|
import 'package:frosty/screens/channel/chat/details/chat_users_list.dart';
|
||||||
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
||||||
import 'package:frosty/screens/settings/settings.dart';
|
import 'package:frosty/screens/settings/settings.dart';
|
||||||
|
import 'package:frosty/utils.dart';
|
||||||
|
import 'package:frosty/utils/modal_bottom_sheet.dart';
|
||||||
import 'package:frosty/widgets/animated_scroll_border.dart';
|
import 'package:frosty/widgets/animated_scroll_border.dart';
|
||||||
|
import 'package:frosty/widgets/frosty_scrollbar.dart';
|
||||||
import 'package:frosty/widgets/section_header.dart';
|
import 'package:frosty/widgets/section_header.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
|
@ -15,11 +23,16 @@ class ChatDetails extends StatefulWidget {
|
||||||
final ChatStore chatStore;
|
final ChatStore chatStore;
|
||||||
final String userLogin;
|
final String userLogin;
|
||||||
|
|
||||||
|
/// Callback to add a new chat tab.
|
||||||
|
/// Shows an "Add chat" option in the menu.
|
||||||
|
final VoidCallback onAddChat;
|
||||||
|
|
||||||
const ChatDetails({
|
const ChatDetails({
|
||||||
super.key,
|
super.key,
|
||||||
required this.chatDetailsStore,
|
required this.chatDetailsStore,
|
||||||
required this.chatStore,
|
required this.chatStore,
|
||||||
required this.userLogin,
|
required this.userLogin,
|
||||||
|
required this.onAddChat,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -29,6 +42,9 @@ class ChatDetails extends StatefulWidget {
|
||||||
class _ChatDetailsState extends State<ChatDetails> {
|
class _ChatDetailsState extends State<ChatDetails> {
|
||||||
late final _scrollController = ScrollController();
|
late final _scrollController = ScrollController();
|
||||||
|
|
||||||
|
bool _isRefreshingAssets = false;
|
||||||
|
bool _showRefreshSuccess = false;
|
||||||
|
|
||||||
String formatDuration(Duration duration) {
|
String formatDuration(Duration duration) {
|
||||||
if (duration.inMinutes < 60) {
|
if (duration.inMinutes < 60) {
|
||||||
return '${duration.inMinutes} ${Intl.plural(duration.inMinutes, one: 'minute', other: 'minutes')}';
|
return '${duration.inMinutes} ${Intl.plural(duration.inMinutes, one: 'minute', other: 'minutes')}';
|
||||||
|
|
@ -43,11 +59,11 @@ class _ChatDetailsState extends State<ChatDetails> {
|
||||||
final seconds = duration.inSeconds.remainder(60);
|
final seconds = duration.inSeconds.remainder(60);
|
||||||
|
|
||||||
if (hours > 0) {
|
if (hours > 0) {
|
||||||
return '${hours}h ${minutes}m left';
|
return '${hours}h ${minutes}m';
|
||||||
} else if (minutes > 0) {
|
} else if (minutes > 0) {
|
||||||
return '${minutes}m ${seconds}s left';
|
return '${minutes}m ${seconds}s';
|
||||||
} else {
|
} else {
|
||||||
return '${seconds}s left';
|
return '${seconds}s';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,7 +87,7 @@ class _ChatDetailsState extends State<ChatDetails> {
|
||||||
Duration(hours: 12),
|
Duration(hours: 12),
|
||||||
];
|
];
|
||||||
|
|
||||||
return showModalBottomSheet(
|
return showModalBottomSheetWithProperFocus(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => Column(
|
builder: (context) => Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
@ -91,10 +107,24 @@ class _ChatDetailsState extends State<ChatDetails> {
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: const Icon(Icons.close_rounded),
|
leading: const Icon(Icons.close_rounded),
|
||||||
title: Text(
|
title: Text.rich(
|
||||||
'Turn off (${formatTimeLeft(widget.chatStore.timeRemaining)})',
|
TextSpan(
|
||||||
style: const TextStyle(
|
text: 'Cancel ',
|
||||||
fontFeatures: [FontFeature.tabularFigures()],
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: formatTimeLeft(
|
||||||
|
widget.chatStore.timeRemaining,
|
||||||
|
),
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodyMedium
|
||||||
|
?.color
|
||||||
|
?.withValues(alpha: 0.6),
|
||||||
|
fontFeatures: [FontFeature.tabularFigures()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
|
@ -128,127 +158,274 @@ class _ChatDetailsState extends State<ChatDetails> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showClearDialog(BuildContext context) {
|
/// Converts a hex color string to a color name if it matches one of our predefined chat colors.
|
||||||
return showDialog(
|
/// Returns null if no match is found.
|
||||||
context: context,
|
String? _hexToColorName(String hexColor) {
|
||||||
builder: (context) => AlertDialog.adaptive(
|
// Remove # if present
|
||||||
title: const Text('Clear recent emotes'),
|
final cleanHex = hexColor.startsWith('#')
|
||||||
content:
|
? hexColor.substring(1)
|
||||||
const Text('Are you sure you want to clear your recent emotes?'),
|
: hexColor;
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: Navigator.of(context).pop,
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
widget.chatStore.assetsStore.recentEmotes.clear();
|
|
||||||
|
|
||||||
Navigator.pop(context);
|
// Convert to uppercase for comparison
|
||||||
},
|
final upperHex = cleanHex.toUpperCase();
|
||||||
child: const Text('Yes'),
|
|
||||||
),
|
// Find matching color name
|
||||||
],
|
for (final entry in chatColorValues.entries) {
|
||||||
|
final colorValue = entry.value.toARGB32().toRadixString(16).toUpperCase();
|
||||||
|
// Remove alpha channel (first 2 characters) for comparison
|
||||||
|
if (colorValue.length >= 6) {
|
||||||
|
final colorHex = colorValue.substring(2);
|
||||||
|
if (colorHex == upperHex) {
|
||||||
|
return entry.key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showChatColorPicker(BuildContext context) async {
|
||||||
|
final selectedColor = await _getCurrentUserColor();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
showModalBottomSheetWithProperFocus(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => SizedBox(
|
||||||
|
height: MediaQuery.of(context).size.height * 0.5,
|
||||||
|
child: _ChatColorPickerModal(
|
||||||
|
initialColor: selectedColor,
|
||||||
|
scrollController: _scrollController,
|
||||||
|
onColorSelected: _handleColorUpdate,
|
||||||
|
chatStore: widget.chatStore,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String?> _getCurrentUserColor() async {
|
||||||
|
try {
|
||||||
|
final currentColorHex = await widget.chatStore.twitchApi.getUserChatColor(
|
||||||
|
userId: widget.chatStore.auth.user.details!.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentColorHex.isNotEmpty) {
|
||||||
|
return _hexToColorName(currentColorHex);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore errors when fetching current color
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleColorUpdate(BuildContext context, String color) async {
|
||||||
|
final success = await widget.chatStore.twitchApi.updateUserChatColor(
|
||||||
|
userId: widget.chatStore.auth.user.details!.id,
|
||||||
|
color: color,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
|
||||||
|
final message = success
|
||||||
|
? 'Chat color updated successfully!'
|
||||||
|
: 'Failed to update chat color. Please try again.';
|
||||||
|
_showMessage(context, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showMessage(BuildContext context, String message) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(message)));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _hasActiveModes() {
|
||||||
|
final roomState = widget.chatDetailsStore.roomState;
|
||||||
|
return roomState.subMode != '0' ||
|
||||||
|
roomState.followersOnly != '-1' ||
|
||||||
|
roomState.emoteOnly != '0' ||
|
||||||
|
roomState.slowMode != '0' ||
|
||||||
|
roomState.r9k != '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRefreshTrailingWidget() {
|
||||||
|
return AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
child: _isRefreshingAssets
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: _showRefreshSuccess
|
||||||
|
? Icon(
|
||||||
|
Icons.check_rounded,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
key: const ValueKey('success'),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleRefreshAssets() async {
|
||||||
|
setState(() => _isRefreshingAssets = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await widget.chatStore.getAssets();
|
||||||
|
if (mounted) {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
setState(() {
|
||||||
|
_isRefreshingAssets = false;
|
||||||
|
_showRefreshSuccess = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide success after 2 seconds
|
||||||
|
Timer(const Duration(seconds: 2), () {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _showRefreshSuccess = false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isRefreshingAssets = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final children = [
|
return Observer(
|
||||||
const SectionHeader(
|
builder: (context) {
|
||||||
'Chat modes',
|
final hasActiveModes = _hasActiveModes();
|
||||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
|
|
||||||
isFirst: true,
|
final children = [
|
||||||
),
|
if (hasActiveModes)
|
||||||
ListTile(
|
ListTile(
|
||||||
title: ChatModes(roomState: widget.chatDetailsStore.roomState),
|
title: SizedBox(
|
||||||
),
|
height: 48,
|
||||||
const SectionHeader(
|
child: ListView(
|
||||||
'More',
|
scrollDirection: Axis.horizontal,
|
||||||
),
|
children: [
|
||||||
ListTile(
|
Observer(
|
||||||
leading: const Icon(Icons.people_outline),
|
builder: (context) => ChatModes(
|
||||||
title: const Text('Chatters'),
|
roomState: widget.chatDetailsStore.roomState,
|
||||||
onTap: () => showModalBottomSheet(
|
),
|
||||||
isScrollControlled: true,
|
),
|
||||||
context: context,
|
],
|
||||||
builder: (context) => GestureDetector(
|
),
|
||||||
onTap: FocusScope.of(context).unfocus,
|
|
||||||
child: ChattersList(
|
|
||||||
chatDetailsStore: widget.chatDetailsStore,
|
|
||||||
chatStore: widget.chatStore,
|
|
||||||
userLogin: widget.userLogin,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Observer(
|
|
||||||
builder: (context) {
|
|
||||||
return ListTile(
|
|
||||||
leading: const Icon(Icons.timer_outlined),
|
|
||||||
title: Text(
|
|
||||||
'Sleep timer ${widget.chatStore.timeRemaining.inSeconds > 0 ? 'on (${formatTimeLeft(widget.chatStore.timeRemaining)})' : ''}',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontFeatures: [FontFeature.tabularFigures()],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onTap: () => _showSleepTimer(context),
|
Observer(
|
||||||
);
|
builder: (context) {
|
||||||
},
|
final showVideo = widget.chatStore.settings.showVideo;
|
||||||
),
|
final label = showVideo ? 'Chat only' : 'Show video';
|
||||||
ListTile(
|
return ListTile(
|
||||||
leading: const Icon(Icons.delete_outline_rounded),
|
leading: Icon(
|
||||||
title: const Text('Clear recent emotes'),
|
showVideo ? Icons.chat_rounded : Icons.tv_rounded,
|
||||||
onTap: () => _showClearDialog(context),
|
),
|
||||||
),
|
title: Text(label),
|
||||||
ListTile(
|
onTap: () {
|
||||||
leading: const Icon(Icons.refresh_rounded),
|
widget.chatStore.settings.showVideo = !showVideo;
|
||||||
title: const Text('Reconnect to chat'),
|
Navigator.of(context).pop();
|
||||||
onTap: () {
|
},
|
||||||
widget.chatStore.updateNotification('Reconnecting to chat...');
|
);
|
||||||
|
},
|
||||||
widget.chatStore.connectToChat();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.refresh_rounded),
|
|
||||||
title: const Text('Refresh badges and emotes'),
|
|
||||||
onTap: () async {
|
|
||||||
await widget.chatStore.getAssets();
|
|
||||||
|
|
||||||
widget.chatStore.updateNotification('Badges and emotes refreshed');
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.chat_outlined),
|
|
||||||
title: Observer(
|
|
||||||
builder: (context) {
|
|
||||||
return Text(
|
|
||||||
'${widget.chatStore.settings.showVideo ? 'Enter' : 'Exit'} chat-only mode',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
onTap: () => widget.chatStore.settings.showVideo =
|
|
||||||
!widget.chatStore.settings.showVideo,
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.settings_outlined),
|
|
||||||
title: const Text('Settings'),
|
|
||||||
onTap: () => Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) =>
|
|
||||||
Settings(settingsStore: widget.chatStore.settings),
|
|
||||||
),
|
),
|
||||||
),
|
ListTile(
|
||||||
),
|
leading: const Icon(Icons.sync_rounded),
|
||||||
];
|
title: const Text('Refresh emotes and badges'),
|
||||||
|
trailing: _buildRefreshTrailingWidget(),
|
||||||
|
enabled: !_isRefreshingAssets,
|
||||||
|
onTap: _isRefreshingAssets ? null : _handleRefreshAssets,
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.wifi_off_rounded),
|
||||||
|
title: const Text('Reconnect'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
widget.chatStore.updateNotification('Reconnecting to chat...');
|
||||||
|
widget.chatStore.connectToChat();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (widget.chatStore.auth.isLoggedIn)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.palette_rounded),
|
||||||
|
title: const Text('Username color'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => _showChatColorPicker(context),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.people_rounded),
|
||||||
|
title: const Text('Chatters'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => showModalBottomSheetWithProperFocus(
|
||||||
|
isScrollControlled: true,
|
||||||
|
context: context,
|
||||||
|
builder: (context) => GestureDetector(
|
||||||
|
onTap: FocusScope.of(context).unfocus,
|
||||||
|
child: ChattersList(
|
||||||
|
chatDetailsStore: widget.chatDetailsStore,
|
||||||
|
chatStore: widget.chatStore,
|
||||||
|
userLogin: widget.userLogin,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.add_comment_rounded),
|
||||||
|
title: const Text('Add chat'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: widget.onAddChat,
|
||||||
|
),
|
||||||
|
Observer(
|
||||||
|
builder: (context) {
|
||||||
|
final hasTimer = widget.chatStore.timeRemaining.inSeconds > 0;
|
||||||
|
final label = hasTimer
|
||||||
|
? formatTimeLeft(widget.chatStore.timeRemaining)
|
||||||
|
: 'Sleep timer';
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
hasTimer ? Icons.timer_rounded : Icons.timer_rounded,
|
||||||
|
color: hasTimer
|
||||||
|
? Theme.of(context).colorScheme.primary
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
label,
|
||||||
|
style: hasTimer
|
||||||
|
? TextStyle(
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
trailing: hasTimer
|
||||||
|
? Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
)
|
||||||
|
: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => _showSleepTimer(context),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.settings_rounded),
|
||||||
|
title: const Text('Settings'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
settings: const RouteSettings(name: Settings.routeName),
|
||||||
|
builder: (context) =>
|
||||||
|
Settings(settingsStore: widget.chatStore.settings),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
return ListView(
|
return ListView(shrinkWrap: true, primary: false, children: children);
|
||||||
shrinkWrap: true,
|
},
|
||||||
primary: false,
|
|
||||||
children: children,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -258,3 +435,187 @@ class _ChatDetailsState extends State<ChatDetails> {
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ChatColorPickerModal extends StatefulWidget {
|
||||||
|
const _ChatColorPickerModal({
|
||||||
|
required this.initialColor,
|
||||||
|
required this.scrollController,
|
||||||
|
required this.onColorSelected,
|
||||||
|
required this.chatStore,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String? initialColor;
|
||||||
|
final ScrollController scrollController;
|
||||||
|
final Future<void> Function(BuildContext context, String color)
|
||||||
|
onColorSelected;
|
||||||
|
final ChatStore chatStore;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ChatColorPickerModal> createState() => _ChatColorPickerModalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChatColorPickerModalState extends State<_ChatColorPickerModal> {
|
||||||
|
String? selectedColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
selectedColor = widget.initialColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildHeader(),
|
||||||
|
_buildPreview(),
|
||||||
|
AnimatedScrollBorder(scrollController: widget.scrollController),
|
||||||
|
_buildColorList(),
|
||||||
|
_buildBottomBar(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader() {
|
||||||
|
return const SectionHeader('Username color', isFirst: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPreview() {
|
||||||
|
if (selectedColor == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
// Create a realistic preview using the current user's chat state and badges
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
|
child: _buildRealisticChatPreview(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRealisticChatPreview() {
|
||||||
|
final userDetails = widget.chatStore.auth.user.details!;
|
||||||
|
final userState = widget.chatStore.userState;
|
||||||
|
|
||||||
|
// Create a mock IRC message with the user's current state
|
||||||
|
final mockTags = <String, String>{
|
||||||
|
'display-name': userDetails.displayName,
|
||||||
|
'color':
|
||||||
|
'#${chatColorValues[selectedColor!]!.toARGB32().toRadixString(16).substring(2)}',
|
||||||
|
'user-id': userDetails.id,
|
||||||
|
'mod': userState.mod ? '1' : '0',
|
||||||
|
'subscriber': userState.subscriber ? '1' : '0',
|
||||||
|
'id': 'preview-message',
|
||||||
|
'tmi-sent-ts': DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Copy any badges from the current user state
|
||||||
|
if (userState.raw != null) {
|
||||||
|
final userStateMessage = IRCMessage.fromString(userState.raw!);
|
||||||
|
if (userStateMessage.tags['badges'] != null) {
|
||||||
|
mockTags['badges'] = userStateMessage.tags['badges']!;
|
||||||
|
}
|
||||||
|
if (userStateMessage.tags['badge-info'] != null) {
|
||||||
|
mockTags['badge-info'] = userStateMessage.tags['badge-info']!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final mockMessage = IRCMessage(
|
||||||
|
raw: '',
|
||||||
|
command: Command.privateMessage,
|
||||||
|
tags: mockTags,
|
||||||
|
user: userDetails.login,
|
||||||
|
message: 'How it will look in chat',
|
||||||
|
split: ['How', 'it', 'will', 'look', 'in', 'chat'],
|
||||||
|
action: false,
|
||||||
|
mention: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
children: mockMessage.generateSpan(
|
||||||
|
context,
|
||||||
|
assetsStore: widget.chatStore.assetsStore,
|
||||||
|
emoteScale: widget.chatStore.settings.emoteScale,
|
||||||
|
badgeScale: widget.chatStore.settings.badgeScale,
|
||||||
|
launchExternal: false, // Disable launching for preview
|
||||||
|
style: DefaultTextStyle.of(context).style,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildColorList() {
|
||||||
|
return Expanded(
|
||||||
|
child: FrostyScrollbar(
|
||||||
|
controller: widget.scrollController,
|
||||||
|
child: ListView(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
controller: widget.scrollController,
|
||||||
|
children: chatColorNames
|
||||||
|
.map((colorName) => _buildColorTile(colorName))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildColorTile(String colorName) {
|
||||||
|
final isSelected = selectedColor == colorName;
|
||||||
|
final displayName = colorName
|
||||||
|
.replaceAll('_', ' ')
|
||||||
|
.split(' ')
|
||||||
|
.map((word) => word[0].toUpperCase() + word.substring(1))
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
// Apply the same color adjustment for consistent preview
|
||||||
|
final rawColor = chatColorValues[colorName]!;
|
||||||
|
final adjustedColor = adjustChatNameColor(context, rawColor);
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: Container(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
decoration: BoxDecoration(color: adjustedColor, shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
title: Text(displayName),
|
||||||
|
trailing: isSelected
|
||||||
|
? Icon(Icons.check, color: Theme.of(context).colorScheme.primary)
|
||||||
|
: null,
|
||||||
|
onTap: () => setState(() => selectedColor = colorName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBottomBar() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
AnimatedScrollBorder(
|
||||||
|
scrollController: widget.scrollController,
|
||||||
|
position: ScrollBorderPosition.bottom,
|
||||||
|
),
|
||||||
|
SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: selectedColor == null
|
||||||
|
? null
|
||||||
|
: () => widget.onColorSelected(context, selectedColor!),
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,16 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store {
|
||||||
Computed<Iterable<String>>? _$filteredUsersComputed;
|
Computed<Iterable<String>>? _$filteredUsersComputed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Iterable<String> get filteredUsers => (_$filteredUsersComputed ??=
|
Iterable<String> get filteredUsers =>
|
||||||
Computed<Iterable<String>>(() => super.filteredUsers,
|
(_$filteredUsersComputed ??= Computed<Iterable<String>>(
|
||||||
name: 'ChatDetailsStoreBase.filteredUsers'))
|
() => super.filteredUsers,
|
||||||
.value;
|
name: 'ChatDetailsStoreBase.filteredUsers',
|
||||||
|
)).value;
|
||||||
|
|
||||||
late final _$roomStateAtom =
|
late final _$roomStateAtom = Atom(
|
||||||
Atom(name: 'ChatDetailsStoreBase.roomState', context: context);
|
name: 'ChatDetailsStoreBase.roomState',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ROOMSTATE get roomState {
|
ROOMSTATE get roomState {
|
||||||
|
|
@ -33,8 +36,10 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
late final _$showJumpButtonAtom =
|
late final _$showJumpButtonAtom = Atom(
|
||||||
Atom(name: 'ChatDetailsStoreBase.showJumpButton', context: context);
|
name: 'ChatDetailsStoreBase.showJumpButton',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get showJumpButton {
|
bool get showJumpButton {
|
||||||
|
|
@ -49,8 +54,10 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
late final _$_filterTextAtom =
|
late final _$_filterTextAtom = Atom(
|
||||||
Atom(name: 'ChatDetailsStoreBase._filterText', context: context);
|
name: 'ChatDetailsStoreBase._filterText',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
String get filterText {
|
String get filterText {
|
||||||
_$_filterTextAtom.reportRead();
|
_$_filterTextAtom.reportRead();
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue