Compare commits
66 commits
dependabot
...
main
| 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 |
164 changed files with 19076 additions and 7542 deletions
|
|
@ -1,31 +0,0 @@
|
||||||
{
|
|
||||||
"enabledMcpjsonServers": ["next-devtools"],
|
|
||||||
"enabledPlugins": {
|
|
||||||
"frontend-design@claude-plugins-official": true,
|
|
||||||
"feature-dev@claude-plugins-official": true,
|
|
||||||
"commit-commands@claude-plugins-official": true,
|
|
||||||
"pr-review-toolkit@claude-plugins-official": true,
|
|
||||||
"typescript-lsp@claude-plugins-official": true,
|
|
||||||
"claude-md-management@claude-plugins-official": true,
|
|
||||||
"claude-code-setup@claude-plugins-official": true,
|
|
||||||
"skill-creator@claude-plugins-official": true
|
|
||||||
},
|
|
||||||
"hooks": {
|
|
||||||
"PostToolUse": [
|
|
||||||
{
|
|
||||||
"matcher": "Edit|Write",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "dart format \"$CLAUDE_FILE_PATH\" 2>/dev/null || true"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"sandbox": {
|
|
||||||
"enabled": true,
|
|
||||||
"autoAllowBashIfSandboxed": true,
|
|
||||||
"enableWeakerNetworkIsolation": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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"
|
||||||
51
.github/workflows/ci.yml
vendored
51
.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@v6
|
- 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
|
||||||
|
|
|
||||||
44
.github/workflows/claude-code-review.yml
vendored
44
.github/workflows/claude-code-review.yml
vendored
|
|
@ -1,44 +0,0 @@
|
||||||
name: Claude Code Review
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [opened, synchronize, ready_for_review, reopened]
|
|
||||||
# Optional: Only run on specific file changes
|
|
||||||
# paths:
|
|
||||||
# - "src/**/*.ts"
|
|
||||||
# - "src/**/*.tsx"
|
|
||||||
# - "src/**/*.js"
|
|
||||||
# - "src/**/*.jsx"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
claude-review:
|
|
||||||
# Optional: Filter by PR author
|
|
||||||
# if: |
|
|
||||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
|
||||||
# github.event.pull_request.user.login == 'new-developer' ||
|
|
||||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: read
|
|
||||||
issues: read
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Run Claude Code Review
|
|
||||||
id: claude-review
|
|
||||||
uses: anthropics/claude-code-action@v1
|
|
||||||
with:
|
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
||||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
|
||||||
plugins: 'code-review@claude-code-plugins'
|
|
||||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
|
||||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
|
||||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
|
||||||
|
|
||||||
50
.github/workflows/claude.yml
vendored
50
.github/workflows/claude.yml
vendored
|
|
@ -1,50 +0,0 @@
|
||||||
name: Claude Code
|
|
||||||
|
|
||||||
on:
|
|
||||||
issue_comment:
|
|
||||||
types: [created]
|
|
||||||
pull_request_review_comment:
|
|
||||||
types: [created]
|
|
||||||
issues:
|
|
||||||
types: [opened, assigned]
|
|
||||||
pull_request_review:
|
|
||||||
types: [submitted]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
claude:
|
|
||||||
if: |
|
|
||||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
||||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
||||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
|
||||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: read
|
|
||||||
issues: read
|
|
||||||
id-token: write
|
|
||||||
actions: read # Required for Claude to read CI results on PRs
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Run Claude Code
|
|
||||||
id: claude
|
|
||||||
uses: anthropics/claude-code-action@v1
|
|
||||||
with:
|
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
||||||
|
|
||||||
# This is an optional setting that allows Claude to read CI results on PRs
|
|
||||||
additional_permissions: |
|
|
||||||
actions: read
|
|
||||||
|
|
||||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
|
||||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
|
||||||
|
|
||||||
# Optional: Add claude_args to customize behavior and configuration
|
|
||||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
|
||||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
|
||||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
|
||||||
|
|
||||||
3
.github/workflows/gh-release.yml
vendored
3
.github/workflows/gh-release.yml
vendored
|
|
@ -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.
|
||||||
|
|
@ -96,7 +97,7 @@ jobs:
|
||||||
|
|
||||||
# Create a draft release on GitHub.
|
# Create a draft release on GitHub.
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
draft: true
|
draft: true
|
||||||
generate_release_notes: ${{ steps.release_notes.outputs.found != 'true' }}
|
generate_release_notes: ${{ steps.release_notes.outputs.found != 'true' }}
|
||||||
|
|
|
||||||
26
.github/workflows/pr-check.yml
vendored
26
.github/workflows/pr-check.yml
vendored
|
|
@ -8,10 +8,36 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- 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
|
||||||
8
.github/workflows/store-release.yml
vendored
8
.github/workflows/store-release.yml
vendored
|
|
@ -35,7 +35,7 @@ jobs:
|
||||||
- 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.
|
||||||
|
|
@ -49,6 +49,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.
|
||||||
|
|
@ -84,7 +85,7 @@ jobs:
|
||||||
- name: Checkout Frosty repo
|
- name: Checkout Frosty repo
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
# Always pick the newest production Xcode on the runner
|
# Always pick the newest production Xcode on the runner.
|
||||||
- uses: maxim-lobanov/setup-xcode@v1
|
- uses: maxim-lobanov/setup-xcode@v1
|
||||||
with:
|
with:
|
||||||
xcode-version: latest-stable
|
xcode-version: latest-stable
|
||||||
|
|
@ -93,13 +94,14 @@ jobs:
|
||||||
- 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).
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -44,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/
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
|
"dart": {
|
||||||
|
"command": "dart",
|
||||||
|
"args": ["mcp-server", "--force-roots-fallback"]
|
||||||
|
},
|
||||||
"next-devtools": {
|
"next-devtools": {
|
||||||
"command": "node",
|
"command": "node",
|
||||||
"args": ["web/node_modules/next-devtools-mcp/dist/index.js"]
|
"args": ["web/node_modules/next-devtools-mcp/dist/index.js"]
|
||||||
|
|
|
||||||
69
AGENTS.md
69
AGENTS.md
|
|
@ -1,55 +1,46 @@
|
||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
> **Monorepo note:** `web/` is a separate Next.js landing page with its own `AGENTS.md`. This file covers the Flutter app only.
|
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`.
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
- In plan mode, interview thoroughly — ask about UI/UX, tradeoffs, and edge cases before coding
|
|
||||||
- When new code supersedes existing functionality, find and remove everything it makes redundant
|
|
||||||
- Favor parallel tool calls and subagents when tasks are independent
|
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
flutter pub get # Install dependencies
|
flutter pub get
|
||||||
flutter run --dart-define=clientId=ID --dart-define=secret=SECRET # Run with Twitch credentials
|
flutter run --dart-define-from-file=.env # local; .env is gitignored
|
||||||
flutter analyze # Static analysis (run after changes)
|
flutter run --dart-define=CLIENT_ID=ID --dart-define=SECRET=SECRET
|
||||||
flutter test # Run tests (if changes touch testable logic)
|
flutter analyze
|
||||||
dart run build_runner build # Regenerate .g.dart files (MobX/JSON models)
|
flutter test
|
||||||
dart run build_runner build --delete-conflicting-outputs # Same, but clears stale outputs
|
flutter test test/path/to/file.dart
|
||||||
|
dart run build_runner build # after MobX / @JsonSerializable changes
|
||||||
```
|
```
|
||||||
|
|
||||||
## Source Structure
|
`--delete-conflicting-outputs` is ignored as of build_runner 2.15. Don't add it.
|
||||||
|
|
||||||
- `lib/apis/` — API services (all extend `BaseApiClient`)
|
## Layout
|
||||||
- `lib/models/` — Data models (`@JsonSerializable` with `.g.dart` codegen)
|
|
||||||
- `lib/screens/{feature}/` — Feature screens with co-located `stores/` subdirectories
|
|
||||||
- `lib/stores/` — Global stores only (e.g., `global_assets_store.dart`)
|
|
||||||
- `lib/services/` — App-level services
|
|
||||||
- `lib/widgets/` — Shared widgets
|
|
||||||
- `lib/utils/` — Utility functions
|
|
||||||
|
|
||||||
## Architecture (non-obvious bits)
|
- `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/`
|
||||||
|
|
||||||
- Feature stores live in `lib/screens/{feature}/stores/` — don't create new top-level store files (only `lib/stores/` holds global stores)
|
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.
|
||||||
- All API services extend `BaseApiClient` (`lib/apis/base_api_client.dart`) — don't create standalone API classes
|
|
||||||
- MobX stores use a generated mixin pattern — see `lib/screens/settings/stores/auth_store.dart` for the canonical example
|
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
|
## Gotchas
|
||||||
|
|
||||||
- After changing MobX stores or `@JsonSerializable` models, regenerate with `dart run build_runner build`. Never edit `.g.dart` files directly. Commit `.g.dart` files to source control.
|
- Commit regenerated `.g.dart` files. Never edit them. CI fails the PR if they're stale.
|
||||||
- The secure storage cleanup in `main.dart` looks unnecessary but handles an Android/iOS edge case where secure storage persists after uninstall. Don't remove it.
|
- `main.dart` secure-storage `deleteAll()` looks redundant; it handles Android/iOS storage surviving uninstall. Don't remove it.
|
||||||
- Use package imports (`import 'package:frosty/...'`), not relative imports
|
- `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.
|
||||||
- Always include trailing commas
|
- 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.
|
||||||
- Use single quotes
|
- `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.
|
||||||
|
|
||||||
## Testing
|
## Releases
|
||||||
|
|
||||||
- `flutter test` runs all tests; `flutter test test/path/to/file.dart` for a single file
|
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.
|
||||||
- HTTP mocking: `http_mock_adapter` (`DioAdapter`) — use full URLs in `onGet`/`onPost`
|
|
||||||
- General mocking: `mocktail` (no codegen required)
|
|
||||||
- Fixtures live in `test/fixtures/` (e.g., `irc_messages.dart`, `api_responses.dart`)
|
|
||||||
|
|
||||||
## Commits
|
|
||||||
|
|
||||||
Lowercase, no prefixes (e.g., `fix landscape bottom padding in chat bottom bar`). Keep commits tightly scoped.
|
|
||||||
|
|
|
||||||
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`.
|
||||||
|
|
@ -16,6 +16,9 @@ analyzer:
|
||||||
- ios/**
|
- ios/**
|
||||||
- android/**
|
- android/**
|
||||||
- web/**
|
- 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
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,13 @@ GEM
|
||||||
specs:
|
specs:
|
||||||
CFPropertyList (3.0.8)
|
CFPropertyList (3.0.8)
|
||||||
abbrev (0.1.2)
|
abbrev (0.1.2)
|
||||||
addressable (2.8.8)
|
addressable (2.9.0)
|
||||||
public_suffix (>= 2.0.2, < 8.0)
|
public_suffix (>= 2.0.2, < 8.0)
|
||||||
artifactory (3.0.17)
|
artifactory (3.0.17)
|
||||||
atomos (0.1.3)
|
atomos (0.1.3)
|
||||||
aws-eventstream (1.4.0)
|
aws-eventstream (1.4.0)
|
||||||
aws-partitions (1.1200.0)
|
aws-partitions (1.1260.0)
|
||||||
aws-sdk-core (3.240.0)
|
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)
|
||||||
|
|
@ -17,18 +17,19 @@ GEM
|
||||||
bigdecimal
|
bigdecimal
|
||||||
jmespath (~> 1, >= 1.6.1)
|
jmespath (~> 1, >= 1.6.1)
|
||||||
logger
|
logger
|
||||||
aws-sdk-kms (1.118.0)
|
aws-sdk-kms (1.129.0)
|
||||||
aws-sdk-core (~> 3, >= 3.239.1)
|
aws-sdk-core (~> 3, >= 3.248.0)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-s3 (1.209.0)
|
aws-sdk-s3 (1.225.1)
|
||||||
aws-sdk-core (~> 3, >= 3.234.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.12.1)
|
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)
|
||||||
bigdecimal (4.0.1)
|
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)
|
||||||
|
|
@ -42,7 +43,7 @@ GEM
|
||||||
dotenv (2.8.1)
|
dotenv (2.8.1)
|
||||||
emoji_regex (3.2.3)
|
emoji_regex (3.2.3)
|
||||||
excon (0.112.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)
|
||||||
|
|
@ -67,19 +68,20 @@ GEM
|
||||||
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.230.0)
|
fastlane (2.234.0)
|
||||||
CFPropertyList (>= 2.3, < 4.0.0)
|
CFPropertyList (>= 2.3, < 5.0.0)
|
||||||
abbrev (~> 0.1.2)
|
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)
|
||||||
base64 (~> 0.2.0)
|
base64 (~> 0.2)
|
||||||
bundler (>= 1.12.0, < 3.0.0)
|
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)
|
csv (~> 3.3)
|
||||||
|
|
@ -90,11 +92,11 @@ 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)
|
||||||
|
|
@ -103,10 +105,11 @@ GEM
|
||||||
logger (>= 1.6, < 2.0)
|
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.0)
|
mutex_m (~> 0.3)
|
||||||
naturally (~> 2.2)
|
naturally (~> 2.2)
|
||||||
nkf (~> 0.2.0)
|
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)
|
||||||
|
|
@ -119,41 +122,42 @@ GEM
|
||||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||||
xcpretty (~> 0.4.1)
|
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.31.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.8.0)
|
|
||||||
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.5.0)
|
google-cloud-errors (1.6.0)
|
||||||
google-cloud-storage (1.47.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.31.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)
|
||||||
|
|
@ -164,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.18.0)
|
json (2.19.9)
|
||||||
jwt (2.10.2)
|
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.19.1)
|
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.3.0)
|
naturally (2.3.0)
|
||||||
nkf (0.2.0)
|
nkf (0.3.0)
|
||||||
optparse (0.8.1)
|
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 (7.0.0)
|
public_suffix (7.0.5)
|
||||||
rake (13.3.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.4)
|
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.21.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, < 4.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)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ plugins {
|
||||||
id("com.google.gms.google-services")
|
id("com.google.gms.google-services")
|
||||||
id("com.google.firebase.firebase-perf")
|
id("com.google.firebase.firebase-perf")
|
||||||
id("com.google.firebase.crashlytics")
|
id("com.google.firebase.crashlytics")
|
||||||
id("kotlin-android")
|
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
@ -27,10 +26,6 @@ android {
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.tommychow.frosty"
|
applicationId = "com.tommychow.frosty"
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
|
|
@ -50,11 +45,26 @@ android {
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
signingConfig = signingConfigs.getByName("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 {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import com.android.build.api.variant.LibraryAndroidComponentsExtension
|
||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
|
|
@ -19,6 +21,17 @@ subprojects {
|
||||||
project.evaluationDependsOn(":app")
|
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") {
|
tasks.register<Delete>("clean") {
|
||||||
delete(rootProject.layout.buildDirectory)
|
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,4 +1,8 @@
|
||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
org.gradle.caching=true
|
org.gradle.caching=true
|
||||||
org.gradle.parallel=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.14-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@ pluginManagement {
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.11.1" apply false
|
id("com.android.application") version "9.1.0" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.2.20" 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.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.firebase-perf") version "2.0.2" apply false
|
||||||
id("com.google.firebase.crashlytics") version "3.0.6" apply false
|
id("com.google.firebase.crashlytics") version "3.0.6" apply false
|
||||||
|
|
|
||||||
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,113 +1,28 @@
|
||||||
## v5.0.5
|
## v5.3.1
|
||||||
|
|
||||||
January 9, 2026
|
October 12, 2026
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- Fixed lag/frame drops in overlay chat mode.
|
- 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.
|
||||||
- Fixed draggable divider still interactable in fullscreen mode.
|
|
||||||
- Fixed landscape bottom padding in chat bottom bar.
|
|
||||||
- [iOS] Fixed user agent breaking login.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v5.0.4
|
## v5.3.0
|
||||||
|
|
||||||
January 8, 2026
|
October 12, 2026
|
||||||
|
|
||||||
### Improvements
|
|
||||||
|
|
||||||
- Swipe down to PiP gesture now works in landscape mode.
|
|
||||||
- The rotate button now adjusts to your device's physical orientation instead of always rotating to the same direction.
|
|
||||||
- Replaced "Fill notch side" setting with "Fill all edges" and made edge padding auto-adjust based on device rotation.
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Fixed Google login not working.
|
|
||||||
- [iOS] Fixed bottom safe area in landscape mode.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v5.0.3
|
|
||||||
|
|
||||||
January 6, 2026
|
|
||||||
|
|
||||||
### Improvements
|
|
||||||
|
|
||||||
- Reconnection messages now update in place instead of spamming chat.
|
|
||||||
- Chat now shows sending status and relevant error notifications when messages fail to send.
|
|
||||||
- [Android] The "Enhanced rendering" video setting has been renamed to "Fast video rendering" and reset to enabled for all users.
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Fixed send button not working in landscape mode.
|
|
||||||
- Fixed text field input being cleared before server confirmation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v5.0.2
|
|
||||||
|
|
||||||
January 4, 2026
|
|
||||||
|
|
||||||
### Improvements
|
|
||||||
|
|
||||||
- Inline emote support in the chat text field.
|
|
||||||
- Search page now uses type-to-search for faster navigation.
|
|
||||||
- Restored ability to send messages while chat delay is active.
|
|
||||||
- Restored chat delay input indicator.
|
|
||||||
- Redesigned the reply bar.
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Fixed reply bar and autocomplete bar blocking the bottom of chat.
|
|
||||||
- Fixed clear recent emotes button sizing.
|
|
||||||
- Fixed black screen when logging in.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v5.0.1
|
|
||||||
|
|
||||||
January 2, 2026
|
|
||||||
|
|
||||||
### Improvements
|
|
||||||
|
|
||||||
- [Android] Enhanced video rendering is now enabled by default for better performance. Disable in settings if you experience crashes.
|
|
||||||
- [Android] Re-enabled Flutter Impeller rendering engine for better performance.
|
|
||||||
- Restored the "Show latency" setting in video overlay options.
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- [Android] Potentially fixed crash on app startup related to secure storage.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v5.0.0
|
|
||||||
|
|
||||||
January 1, 2026
|
|
||||||
|
|
||||||
### New Features
|
### New Features
|
||||||
|
|
||||||
- Open multiple chat tabs for different channels with drag-to-reorder. Tabs are saved between sessions by default (configurable in settings).
|
- Added a Follow / Unfollow button under the player (between video and chat) for live and offline channels.
|
||||||
- Resize the chat and video areas in landscape mode by dragging the divider.
|
|
||||||
- A new section in the Following tab shows which of your followed channels are currently offline.
|
|
||||||
- Change your chat username color from the chat details menu.
|
|
||||||
- A new randomize button for the accent color theme in settings.
|
|
||||||
- View emotes and stream thumbnails in full resolution with swipe-to-dismiss gestures.
|
|
||||||
- [Android] Open Twitch channel links directly in Frosty instead of the browser or Twitch app (by @micahmo on GitHub).
|
|
||||||
|
|
||||||
### Improvements
|
---
|
||||||
|
|
||||||
- Swipe down to enter Picture-in-Picture mode, and press the PiP button again to exit.
|
## v5.2.9
|
||||||
- Redesigned the video overlay and added stream details to the app bar in chat-only mode.
|
|
||||||
- Networking and data handling have been completely rewritten to be faster and more reliable.
|
October 7, 2026
|
||||||
- Improved formatting for chat replies, quick copy-to-input for messages, and a clearer countdown for chat delays.
|
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- Fixed chat text input not unfocusing when tapping outside of it (by @zeykafx on GitHub).
|
- Fixed tag stream lists reshuffling when loading more pages by paginating with Twitch’s sorted tag directory (GQL) instead of scanning Helix.
|
||||||
- Fixed missing badges and emotes in historical messages upon initial connection.
|
- Fixed follow/unfollow failing with an integrity check error while logged in by confirming via a short Twitch WebView when needed.
|
||||||
- Fixed accidental chat scrolling when accessing system UI gestures in landscape mode.
|
|
||||||
- Fixed chat reconnection issues when a chat delay was active.
|
|
||||||
- Fixed duplicate message cases and various UI stability issues.
|
|
||||||
|
|
||||||
#### See all release notes at [https://github.com/tommyxchow/frosty/releases](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>15.0</string>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
115
ios/Gemfile.lock
115
ios/Gemfile.lock
|
|
@ -3,13 +3,13 @@ GEM
|
||||||
specs:
|
specs:
|
||||||
CFPropertyList (3.0.8)
|
CFPropertyList (3.0.8)
|
||||||
abbrev (0.1.2)
|
abbrev (0.1.2)
|
||||||
addressable (2.8.8)
|
addressable (2.9.0)
|
||||||
public_suffix (>= 2.0.2, < 8.0)
|
public_suffix (>= 2.0.2, < 8.0)
|
||||||
artifactory (3.0.17)
|
artifactory (3.0.17)
|
||||||
atomos (0.1.3)
|
atomos (0.1.3)
|
||||||
aws-eventstream (1.4.0)
|
aws-eventstream (1.4.0)
|
||||||
aws-partitions (1.1200.0)
|
aws-partitions (1.1260.0)
|
||||||
aws-sdk-core (3.240.0)
|
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)
|
||||||
|
|
@ -17,18 +17,19 @@ GEM
|
||||||
bigdecimal
|
bigdecimal
|
||||||
jmespath (~> 1, >= 1.6.1)
|
jmespath (~> 1, >= 1.6.1)
|
||||||
logger
|
logger
|
||||||
aws-sdk-kms (1.118.0)
|
aws-sdk-kms (1.129.0)
|
||||||
aws-sdk-core (~> 3, >= 3.239.1)
|
aws-sdk-core (~> 3, >= 3.248.0)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-s3 (1.209.0)
|
aws-sdk-s3 (1.225.1)
|
||||||
aws-sdk-core (~> 3, >= 3.234.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.12.1)
|
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)
|
||||||
bigdecimal (4.0.1)
|
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)
|
||||||
|
|
@ -42,7 +43,7 @@ GEM
|
||||||
dotenv (2.8.1)
|
dotenv (2.8.1)
|
||||||
emoji_regex (3.2.3)
|
emoji_regex (3.2.3)
|
||||||
excon (0.112.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)
|
||||||
|
|
@ -67,19 +68,20 @@ GEM
|
||||||
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.230.0)
|
fastlane (2.234.0)
|
||||||
CFPropertyList (>= 2.3, < 4.0.0)
|
CFPropertyList (>= 2.3, < 5.0.0)
|
||||||
abbrev (~> 0.1.2)
|
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)
|
||||||
base64 (~> 0.2.0)
|
base64 (~> 0.2)
|
||||||
bundler (>= 1.12.0, < 3.0.0)
|
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)
|
csv (~> 3.3)
|
||||||
|
|
@ -90,11 +92,11 @@ 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)
|
||||||
|
|
@ -103,10 +105,11 @@ GEM
|
||||||
logger (>= 1.6, < 2.0)
|
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.0)
|
mutex_m (~> 0.3)
|
||||||
naturally (~> 2.2)
|
naturally (~> 2.2)
|
||||||
nkf (~> 0.2.0)
|
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)
|
||||||
|
|
@ -119,41 +122,42 @@ GEM
|
||||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||||
xcpretty (~> 0.4.1)
|
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.31.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.8.0)
|
|
||||||
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.5.0)
|
google-cloud-errors (1.6.0)
|
||||||
google-cloud-storage (1.47.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.31.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)
|
||||||
|
|
@ -164,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.18.0)
|
json (2.19.9)
|
||||||
jwt (2.10.2)
|
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.19.1)
|
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.3.0)
|
naturally (2.3.0)
|
||||||
nkf (0.2.0)
|
nkf (0.3.0)
|
||||||
optparse (0.8.1)
|
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 (7.0.0)
|
public_suffix (7.0.5)
|
||||||
rake (13.3.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.4)
|
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.21.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, < 4.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)
|
||||||
|
|
|
||||||
293
ios/Podfile.lock
293
ios/Podfile.lock
|
|
@ -1,314 +1,27 @@
|
||||||
PODS:
|
PODS:
|
||||||
- advanced_in_app_review (0.0.1):
|
- advanced_in_app_review (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- app_links (7.0.0):
|
|
||||||
- Flutter
|
|
||||||
- device_info_plus (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- Firebase/CoreOnly (12.9.0):
|
|
||||||
- FirebaseCore (~> 12.9.0)
|
|
||||||
- Firebase/Crashlytics (12.9.0):
|
|
||||||
- Firebase/CoreOnly
|
|
||||||
- FirebaseCrashlytics (~> 12.9.0)
|
|
||||||
- Firebase/Performance (12.9.0):
|
|
||||||
- Firebase/CoreOnly
|
|
||||||
- FirebasePerformance (~> 12.9.0)
|
|
||||||
- firebase_analytics (12.1.3):
|
|
||||||
- firebase_core
|
|
||||||
- FirebaseAnalytics (= 12.9.0)
|
|
||||||
- Flutter
|
|
||||||
- firebase_core (4.5.0):
|
|
||||||
- Firebase/CoreOnly (= 12.9.0)
|
|
||||||
- Flutter
|
|
||||||
- firebase_crashlytics (5.0.8):
|
|
||||||
- Firebase/Crashlytics (= 12.9.0)
|
|
||||||
- firebase_core
|
|
||||||
- Flutter
|
|
||||||
- firebase_performance (0.11.1-5):
|
|
||||||
- Firebase/Performance (= 12.9.0)
|
|
||||||
- firebase_core
|
|
||||||
- Flutter
|
|
||||||
- FirebaseABTesting (12.9.0):
|
|
||||||
- FirebaseCore (~> 12.9.0)
|
|
||||||
- FirebaseAnalytics (12.9.0):
|
|
||||||
- FirebaseAnalytics/Default (= 12.9.0)
|
|
||||||
- FirebaseCore (~> 12.9.0)
|
|
||||||
- FirebaseInstallations (~> 12.9.0)
|
|
||||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
|
||||||
- GoogleUtilities/MethodSwizzler (~> 8.1)
|
|
||||||
- GoogleUtilities/Network (~> 8.1)
|
|
||||||
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
|
||||||
- nanopb (~> 3.30910.0)
|
|
||||||
- FirebaseAnalytics/Default (12.9.0):
|
|
||||||
- FirebaseCore (~> 12.9.0)
|
|
||||||
- FirebaseInstallations (~> 12.9.0)
|
|
||||||
- GoogleAppMeasurement/Default (= 12.9.0)
|
|
||||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
|
||||||
- GoogleUtilities/MethodSwizzler (~> 8.1)
|
|
||||||
- GoogleUtilities/Network (~> 8.1)
|
|
||||||
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
|
||||||
- nanopb (~> 3.30910.0)
|
|
||||||
- FirebaseCore (12.9.0):
|
|
||||||
- FirebaseCoreInternal (~> 12.9.0)
|
|
||||||
- GoogleUtilities/Environment (~> 8.1)
|
|
||||||
- GoogleUtilities/Logger (~> 8.1)
|
|
||||||
- FirebaseCoreExtension (12.9.0):
|
|
||||||
- FirebaseCore (~> 12.9.0)
|
|
||||||
- FirebaseCoreInternal (12.9.0):
|
|
||||||
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
|
||||||
- FirebaseCrashlytics (12.9.0):
|
|
||||||
- FirebaseCore (~> 12.9.0)
|
|
||||||
- FirebaseInstallations (~> 12.9.0)
|
|
||||||
- FirebaseRemoteConfigInterop (~> 12.9.0)
|
|
||||||
- FirebaseSessions (~> 12.9.0)
|
|
||||||
- GoogleDataTransport (~> 10.1)
|
|
||||||
- GoogleUtilities/Environment (~> 8.1)
|
|
||||||
- nanopb (~> 3.30910.0)
|
|
||||||
- PromisesObjC (~> 2.4)
|
|
||||||
- FirebaseInstallations (12.9.0):
|
|
||||||
- FirebaseCore (~> 12.9.0)
|
|
||||||
- GoogleUtilities/Environment (~> 8.1)
|
|
||||||
- GoogleUtilities/UserDefaults (~> 8.1)
|
|
||||||
- PromisesObjC (~> 2.4)
|
|
||||||
- FirebasePerformance (12.9.0):
|
|
||||||
- FirebaseCore (~> 12.9.0)
|
|
||||||
- FirebaseInstallations (~> 12.9.0)
|
|
||||||
- FirebaseRemoteConfig (~> 12.9.0)
|
|
||||||
- FirebaseSessions (~> 12.9.0)
|
|
||||||
- GoogleDataTransport (~> 10.1)
|
|
||||||
- GoogleUtilities/Environment (~> 8.1)
|
|
||||||
- GoogleUtilities/MethodSwizzler (~> 8.1)
|
|
||||||
- GoogleUtilities/UserDefaults (~> 8.1)
|
|
||||||
- nanopb (~> 3.30910.0)
|
|
||||||
- FirebaseRemoteConfig (12.9.0):
|
|
||||||
- FirebaseABTesting (~> 12.9.0)
|
|
||||||
- FirebaseCore (~> 12.9.0)
|
|
||||||
- FirebaseInstallations (~> 12.9.0)
|
|
||||||
- FirebaseRemoteConfigInterop (~> 12.9.0)
|
|
||||||
- FirebaseSharedSwift (~> 12.9.0)
|
|
||||||
- GoogleUtilities/Environment (~> 8.1)
|
|
||||||
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
|
||||||
- FirebaseRemoteConfigInterop (12.9.0)
|
|
||||||
- FirebaseSessions (12.9.0):
|
|
||||||
- FirebaseCore (~> 12.9.0)
|
|
||||||
- FirebaseCoreExtension (~> 12.9.0)
|
|
||||||
- FirebaseInstallations (~> 12.9.0)
|
|
||||||
- GoogleDataTransport (~> 10.1)
|
|
||||||
- GoogleUtilities/Environment (~> 8.1)
|
|
||||||
- GoogleUtilities/UserDefaults (~> 8.1)
|
|
||||||
- nanopb (~> 3.30910.0)
|
|
||||||
- PromisesSwift (~> 2.1)
|
|
||||||
- FirebaseSharedSwift (12.9.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
|
||||||
- GoogleAdsOnDeviceConversion (3.2.0):
|
|
||||||
- GoogleUtilities/Environment (~> 8.1)
|
|
||||||
- GoogleUtilities/Logger (~> 8.1)
|
|
||||||
- GoogleUtilities/Network (~> 8.1)
|
|
||||||
- nanopb (~> 3.30910.0)
|
|
||||||
- GoogleAppMeasurement/Core (12.9.0):
|
|
||||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
|
||||||
- GoogleUtilities/MethodSwizzler (~> 8.1)
|
|
||||||
- GoogleUtilities/Network (~> 8.1)
|
|
||||||
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
|
||||||
- nanopb (~> 3.30910.0)
|
|
||||||
- GoogleAppMeasurement/Default (12.9.0):
|
|
||||||
- GoogleAdsOnDeviceConversion (~> 3.2.0)
|
|
||||||
- GoogleAppMeasurement/Core (= 12.9.0)
|
|
||||||
- GoogleAppMeasurement/IdentitySupport (= 12.9.0)
|
|
||||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
|
||||||
- GoogleUtilities/MethodSwizzler (~> 8.1)
|
|
||||||
- GoogleUtilities/Network (~> 8.1)
|
|
||||||
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
|
||||||
- nanopb (~> 3.30910.0)
|
|
||||||
- GoogleAppMeasurement/IdentitySupport (12.9.0):
|
|
||||||
- GoogleAppMeasurement/Core (= 12.9.0)
|
|
||||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
|
||||||
- GoogleUtilities/MethodSwizzler (~> 8.1)
|
|
||||||
- GoogleUtilities/Network (~> 8.1)
|
|
||||||
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
|
||||||
- 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)
|
|
||||||
- native_device_orientation (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- package_info_plus (0.4.5):
|
|
||||||
- Flutter
|
|
||||||
- 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`)
|
||||||
- app_links (from `.symlinks/plugins/app_links/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`)
|
|
||||||
- native_device_orientation (from `.symlinks/plugins/native_device_orientation/ios`)
|
|
||||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
|
||||||
- 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
|
|
||||||
- GoogleAdsOnDeviceConversion
|
|
||||||
- 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"
|
||||||
app_links:
|
|
||||||
:path: ".symlinks/plugins/app_links/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"
|
|
||||||
native_device_orientation:
|
|
||||||
:path: ".symlinks/plugins/native_device_orientation/ios"
|
|
||||||
package_info_plus:
|
|
||||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
|
||||||
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
|
||||||
app_links: 6d01271b3907b0ee7325c5297c75d697c4226c4d
|
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
|
||||||
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
|
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
||||||
Firebase: 065f2bb395062046623036d8e6dc857bc2521d56
|
|
||||||
firebase_analytics: b5c909bdc321fab0f9ddf8712f5cef79057e09fa
|
|
||||||
firebase_core: 8f0a8c3aca22e29012e419ee18109b4aa021f777
|
|
||||||
firebase_crashlytics: 4b21fc111f15e814eeab83f90a71efe3c1e9399a
|
|
||||||
firebase_performance: 0a30e8afaae656fdf6fa271ac3c62dc26b74d7a1
|
|
||||||
FirebaseABTesting: a399ffe546392a39b19a5c2fb28bd8ea178a6f47
|
|
||||||
FirebaseAnalytics: cd7d01d352f3c237c9a0e31552c257cd0b0c0352
|
|
||||||
FirebaseCore: 428912f751178b06bef0a1793effeb4a5e09a9b8
|
|
||||||
FirebaseCoreExtension: e911052d59cd0da237a45d706fc0f81654f035c1
|
|
||||||
FirebaseCoreInternal: b321eafae5362113bc182956fafc9922cfc77b72
|
|
||||||
FirebaseCrashlytics: 43913d587ef07beaf5db703baa61eacf9554658c
|
|
||||||
FirebaseInstallations: 7b64ffd006032b2b019a59b803858df5112d9eaa
|
|
||||||
FirebasePerformance: 94f614453614d8bb2a1a0177f3a1a6d2dbf4c504
|
|
||||||
FirebaseRemoteConfig: a2f6545e41551ffb520241d38b5d3d6776c9ebe8
|
|
||||||
FirebaseRemoteConfigInterop: 765ee19cd2bfa8e54937c8dae901eb634ad6787d
|
|
||||||
FirebaseSessions: a2d06fd980431fda934c7a543901aca05fc4edcc
|
|
||||||
FirebaseSharedSwift: 9d2fa84a46676302b89dbd5e6e62bce2fe376909
|
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
|
||||||
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
|
|
||||||
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
|
|
||||||
GoogleAdsOnDeviceConversion: d68c69dd9581a0f5da02617b6f377e5be483970f
|
|
||||||
GoogleAppMeasurement: fce7c1c90640d2f9f5c56771f71deacb2ba3f98c
|
|
||||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
|
||||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
|
||||||
in_app_review: 436034b18594851a7328d7f1c2ed5ec235b79cfc
|
|
||||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
|
||||||
native_device_orientation: 42487b19c552fb5a6a2b51e61b93366f628113df
|
|
||||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
|
||||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
|
||||||
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
|
|
||||||
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
|
|
||||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
|
||||||
url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa
|
|
||||||
wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e
|
|
||||||
webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4
|
|
||||||
|
|
||||||
PODFILE CHECKSUM: 57c8aed26fba39d3ec9424816221f294a07c58eb
|
PODFILE CHECKSUM: 57c8aed26fba39d3ec9424816221f294a07c58eb
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,11 +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 */; };
|
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 */
|
||||||
|
|
@ -38,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>"; };
|
||||||
|
|
@ -51,6 +54,7 @@
|
||||||
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>"; };
|
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 */
|
||||||
|
|
@ -58,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;
|
||||||
|
|
@ -68,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 */,
|
||||||
|
|
@ -106,6 +112,7 @@
|
||||||
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 */,
|
D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */,
|
||||||
);
|
);
|
||||||
|
|
@ -134,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 = (
|
||||||
|
|
@ -160,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;
|
||||||
|
|
@ -246,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;
|
||||||
|
|
@ -304,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;
|
||||||
|
|
@ -590,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>
|
||||||
|
|
|
||||||
|
|
@ -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 @@
|
||||||
Frosty for Twitch
|
Frosty
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,13 @@ class UnauthorizedException extends ApiException {
|
||||||
: super(message, 401, DioExceptionType.badResponse);
|
: 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
|
/// Base API client to eliminate code duplication across API services
|
||||||
abstract class BaseApiClient {
|
abstract class BaseApiClient {
|
||||||
final Dio _dio;
|
final Dio _dio;
|
||||||
|
|
@ -161,7 +168,8 @@ abstract class BaseApiClient {
|
||||||
final exception = switch (error.type) {
|
final exception = switch (error.type) {
|
||||||
DioExceptionType.connectionTimeout ||
|
DioExceptionType.connectionTimeout ||
|
||||||
DioExceptionType.sendTimeout ||
|
DioExceptionType.sendTimeout ||
|
||||||
DioExceptionType.receiveTimeout =>
|
DioExceptionType.receiveTimeout ||
|
||||||
|
DioExceptionType.transformTimeout =>
|
||||||
TimeoutException(_getTimeoutMessage(error.type)),
|
TimeoutException(_getTimeoutMessage(error.type)),
|
||||||
DioExceptionType.connectionError => NetworkException(
|
DioExceptionType.connectionError => NetworkException(
|
||||||
'No internet connection. Please check your network.',
|
'No internet connection. Please check your network.',
|
||||||
|
|
@ -202,6 +210,8 @@ abstract class BaseApiClient {
|
||||||
return 'Upload timeout. Please try again.';
|
return 'Upload timeout. Please try again.';
|
||||||
case DioExceptionType.receiveTimeout:
|
case DioExceptionType.receiveTimeout:
|
||||||
return 'Server response timeout. Please try again.';
|
return 'Server response timeout. Please try again.';
|
||||||
|
case DioExceptionType.transformTimeout:
|
||||||
|
return 'Response processing timeout. Please try again.';
|
||||||
default:
|
default:
|
||||||
return 'Request timeout. Please try again.';
|
return 'Request timeout. Please try again.';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ class FFZApi extends BaseApiClient {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 data = await get<JsonMap>('/badges/ids');
|
final data = await get<JsonMap>('/badges/ids');
|
||||||
|
|
||||||
|
|
|
||||||
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';
|
||||||
|
|
@ -145,17 +145,29 @@ class TwitchApi extends BaseApiClient {
|
||||||
return data['access_token'] as String;
|
return data['access_token'] as String;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a bool indicating the validity of the given token.
|
/// Validates [token] with Twitch.
|
||||||
Future<bool> validateToken({required String token}) async {
|
///
|
||||||
|
/// Returns token info (scopes + optional `user_id`) when valid, or `null`
|
||||||
|
/// when invalid/expired. App-access tokens may omit `user_id` and return an
|
||||||
|
/// empty scope list.
|
||||||
|
Future<TwitchTokenInfo?> validateToken({required String token}) async {
|
||||||
try {
|
try {
|
||||||
await get<JsonMap>(
|
final data = await get<JsonMap>(
|
||||||
'$_oauthBaseUrl/validate',
|
'$_oauthBaseUrl/validate',
|
||||||
headers: {'Authorization': 'Bearer $token'},
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
);
|
);
|
||||||
return true;
|
final scopes = data['scopes'];
|
||||||
|
final scopeList = scopes is List
|
||||||
|
? scopes.map((scope) => scope.toString()).toList()
|
||||||
|
: const <String>[];
|
||||||
|
final userId = data['user_id']?.toString();
|
||||||
|
return TwitchTokenInfo(
|
||||||
|
scopes: scopeList,
|
||||||
|
userId: userId == null || userId.isEmpty ? null : userId,
|
||||||
|
);
|
||||||
} on UnauthorizedException {
|
} on UnauthorizedException {
|
||||||
// 401 -> token is invalid/expired (propagated from interceptor for validate requests)
|
// 401 -> token is invalid/expired (propagated from interceptor for validate requests)
|
||||||
return false;
|
return null;
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
// Network/timeout/server errors -> treat as indeterminate, not invalid
|
// Network/timeout/server errors -> treat as indeterminate, not invalid
|
||||||
debugPrint('Token validation indeterminate: $e');
|
debugPrint('Token validation indeterminate: $e');
|
||||||
|
|
@ -163,11 +175,23 @@ class TwitchApi extends BaseApiClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a [StreamsTwitch] object that contains the top 20 streams and a cursor for further requests.
|
/// Returns a [StreamsTwitch] object that contains top streams and a cursor for further requests.
|
||||||
Future<StreamsTwitch> getTopStreams({String? cursor}) async {
|
///
|
||||||
|
/// [language] is an optional ISO 639-1 code (Helix accepts only one).
|
||||||
|
Future<StreamsTwitch> getTopStreams({
|
||||||
|
String? cursor,
|
||||||
|
String? language,
|
||||||
|
int first = 100,
|
||||||
|
}) async {
|
||||||
|
final queryParams = <String, dynamic>{
|
||||||
|
'first': first,
|
||||||
|
if (cursor != null) 'after': cursor,
|
||||||
|
if (language != null && language.isNotEmpty) 'language': language,
|
||||||
|
};
|
||||||
|
|
||||||
final data = await get<JsonMap>(
|
final data = await get<JsonMap>(
|
||||||
'/streams',
|
'/streams',
|
||||||
queryParameters: cursor != null ? {'after': cursor} : null,
|
queryParameters: queryParams,
|
||||||
);
|
);
|
||||||
|
|
||||||
return StreamsTwitch.fromJson(data);
|
return StreamsTwitch.fromJson(data);
|
||||||
|
|
@ -178,7 +202,7 @@ class TwitchApi extends BaseApiClient {
|
||||||
required String id,
|
required String id,
|
||||||
String? cursor,
|
String? cursor,
|
||||||
}) async {
|
}) async {
|
||||||
final queryParams = {'user_id': id};
|
final queryParams = {'user_id': id, 'first': '100'};
|
||||||
if (cursor != null) queryParams['after'] = cursor;
|
if (cursor != null) queryParams['after'] = cursor;
|
||||||
|
|
||||||
final data = await get<JsonMap>(
|
final data = await get<JsonMap>(
|
||||||
|
|
@ -194,7 +218,7 @@ class TwitchApi extends BaseApiClient {
|
||||||
required String userId,
|
required String userId,
|
||||||
String? cursor,
|
String? cursor,
|
||||||
}) async {
|
}) async {
|
||||||
final queryParams = {'user_id': userId, 'first': '20'};
|
final queryParams = {'user_id': userId, 'first': '100'};
|
||||||
if (cursor != null) queryParams['after'] = cursor;
|
if (cursor != null) queryParams['after'] = cursor;
|
||||||
|
|
||||||
final data = await get<JsonMap>(
|
final data = await get<JsonMap>(
|
||||||
|
|
@ -205,19 +229,153 @@ class TwitchApi extends BaseApiClient {
|
||||||
return FollowedChannels.fromJson(data);
|
return FollowedChannels.fromJson(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether [userId] follows [broadcasterId], via Helix (read-only; create/delete
|
||||||
|
/// follows were removed from the public API).
|
||||||
|
Future<bool> isFollowingChannel({
|
||||||
|
required String userId,
|
||||||
|
required String broadcasterId,
|
||||||
|
}) async {
|
||||||
|
final data = await get<JsonMap>(
|
||||||
|
'/channels/followed',
|
||||||
|
queryParameters: {
|
||||||
|
'user_id': userId,
|
||||||
|
'broadcaster_id': broadcasterId,
|
||||||
|
'first': '1',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final list = data['data'] as List? ?? const [];
|
||||||
|
return list.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns a [StreamsTwitch] object that contains the list of streams under the given game/category ID.
|
/// Returns a [StreamsTwitch] object that contains the list of streams under the given game/category ID.
|
||||||
Future<StreamsTwitch> getStreamsUnderCategory({
|
Future<StreamsTwitch> getStreamsUnderCategory({
|
||||||
required String gameId,
|
required String gameId,
|
||||||
String? cursor,
|
String? cursor,
|
||||||
|
String? language,
|
||||||
|
int first = 100,
|
||||||
}) async {
|
}) async {
|
||||||
final queryParams = {'game_id': gameId};
|
final queryParams = <String, dynamic>{
|
||||||
if (cursor != null) queryParams['after'] = cursor;
|
'game_id': gameId,
|
||||||
|
'first': first,
|
||||||
|
if (cursor != null) 'after': cursor,
|
||||||
|
if (language != null && language.isNotEmpty) 'language': language,
|
||||||
|
};
|
||||||
|
|
||||||
final data = await get<JsonMap>('/streams', queryParameters: queryParams);
|
final data = await get<JsonMap>('/streams', queryParameters: queryParams);
|
||||||
|
|
||||||
return StreamsTwitch.fromJson(data);
|
return StreamsTwitch.fromJson(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetches Helix directory pages until enough streams match [languages] and
|
||||||
|
/// optional [freeformTag] (case-insensitive tag name), or the directory ends.
|
||||||
|
///
|
||||||
|
/// Helix is always viewer-desc and supports at most one `language` query
|
||||||
|
/// param; tags are filtered client-side. Used as fallback when GQL directory
|
||||||
|
/// pagination lacks a Client-Integrity token.
|
||||||
|
///
|
||||||
|
/// When a [freeformTag] filter yields no matches in a Helix window, this
|
||||||
|
/// keeps scanning (up to [maxPages]) and still returns the Helix cursor so
|
||||||
|
/// callers can continue — an empty match set alone is not end-of-list.
|
||||||
|
Future<StreamsTwitch> getDirectoryStreams({
|
||||||
|
List<String> languages = const [],
|
||||||
|
String? gameId,
|
||||||
|
String? freeformTag,
|
||||||
|
String? cursor,
|
||||||
|
int minCount = 50,
|
||||||
|
int maxPages = 12,
|
||||||
|
}) async {
|
||||||
|
final wantedLangs = languages
|
||||||
|
.map((l) => l.trim().toLowerCase())
|
||||||
|
.where((l) => l.isNotEmpty)
|
||||||
|
.toSet();
|
||||||
|
final tagNeedle = freeformTag?.trim().toLowerCase();
|
||||||
|
final hasTag = tagNeedle != null && tagNeedle.isNotEmpty;
|
||||||
|
|
||||||
|
bool matches(StreamTwitch stream) {
|
||||||
|
if (wantedLangs.isNotEmpty &&
|
||||||
|
!wantedLangs.contains(stream.language.toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (hasTag) {
|
||||||
|
final hit = stream.tags.any((t) => t.toLowerCase() == tagNeedle);
|
||||||
|
if (!hit) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast path: no tag filter and 0–1 languages → one Helix call.
|
||||||
|
if (!hasTag && wantedLangs.length <= 1) {
|
||||||
|
final lang = wantedLangs.isEmpty ? null : wantedLangs.first;
|
||||||
|
return gameId == null
|
||||||
|
? getTopStreams(cursor: cursor, language: lang)
|
||||||
|
: getStreamsUnderCategory(
|
||||||
|
gameId: gameId,
|
||||||
|
cursor: cursor,
|
||||||
|
language: lang,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sparse tags need a deeper Helix scan per request.
|
||||||
|
final effectiveMin = hasTag ? minCount.clamp(1, 100) : minCount;
|
||||||
|
final effectiveMax = hasTag ? (maxPages < 25 ? 25 : maxPages) : maxPages;
|
||||||
|
|
||||||
|
// When filtering by tag and exactly one language, still pass language to
|
||||||
|
// Helix so each page is denser in matches.
|
||||||
|
final helixLanguage =
|
||||||
|
wantedLangs.length == 1 ? wantedLangs.first : null;
|
||||||
|
|
||||||
|
final matched = <StreamTwitch>[];
|
||||||
|
String? pageCursor = cursor;
|
||||||
|
String? nextCursor;
|
||||||
|
var pages = 0;
|
||||||
|
|
||||||
|
while (matched.length < effectiveMin && pages < effectiveMax) {
|
||||||
|
final page = gameId == null
|
||||||
|
? await getTopStreams(cursor: pageCursor, language: helixLanguage)
|
||||||
|
: await getStreamsUnderCategory(
|
||||||
|
gameId: gameId,
|
||||||
|
cursor: pageCursor,
|
||||||
|
language: helixLanguage,
|
||||||
|
);
|
||||||
|
pages++;
|
||||||
|
nextCursor = page.pagination['cursor'];
|
||||||
|
for (final stream in page.data) {
|
||||||
|
if (matches(stream)) matched.add(stream);
|
||||||
|
}
|
||||||
|
if (nextCursor == null ||
|
||||||
|
nextCursor.isEmpty ||
|
||||||
|
page.data.isEmpty) {
|
||||||
|
nextCursor = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pageCursor = nextCursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return StreamsTwitch(
|
||||||
|
matched,
|
||||||
|
nextCursor != null && nextCursor.isNotEmpty
|
||||||
|
? {'cursor': nextCursor}
|
||||||
|
: const {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deprecated name — use [getDirectoryStreams].
|
||||||
|
Future<StreamsTwitch> getStreamsMatchingLanguages({
|
||||||
|
required List<String> languages,
|
||||||
|
String? gameId,
|
||||||
|
String? cursor,
|
||||||
|
int minCount = 40,
|
||||||
|
int maxPages = 8,
|
||||||
|
}) =>
|
||||||
|
getDirectoryStreams(
|
||||||
|
languages: languages,
|
||||||
|
gameId: gameId,
|
||||||
|
cursor: cursor,
|
||||||
|
minCount: minCount,
|
||||||
|
maxPages: maxPages,
|
||||||
|
);
|
||||||
|
|
||||||
/// Returns a [StreamTwitch] object containing the stream info associated with the given [userLogin].
|
/// Returns a [StreamTwitch] object containing the stream info associated with the given [userLogin].
|
||||||
Future<StreamTwitch> getStream({required String userLogin}) async {
|
Future<StreamTwitch> getStream({required String userLogin}) async {
|
||||||
final data = await get<JsonMap>(
|
final data = await get<JsonMap>(
|
||||||
|
|
@ -465,4 +623,131 @@ class TwitchApi extends BaseApiClient {
|
||||||
|
|
||||||
return data['messages'] as JsonList;
|
return data['messages'] as JsonList;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a list of broadcaster IDs for channels the authenticated user
|
||||||
|
/// moderates, following pagination so moderators of more than one page of
|
||||||
|
/// channels aren't silently truncated at the first 100.
|
||||||
|
Future<List<String>> getModeratedChannels({
|
||||||
|
required String id,
|
||||||
|
String? cursor,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
// Default page size is 20; request the max to keep the request count low.
|
||||||
|
final queryParams = {'user_id': id, 'first': '100'};
|
||||||
|
if (cursor != null) queryParams['after'] = cursor;
|
||||||
|
|
||||||
|
final data = await get<JsonMap>(
|
||||||
|
'/moderation/channels',
|
||||||
|
queryParameters: queryParams,
|
||||||
|
);
|
||||||
|
|
||||||
|
final channels = data['data'] as JsonList;
|
||||||
|
final result = channels
|
||||||
|
.map((channel) => (channel as JsonMap)['broadcaster_id'] as String?)
|
||||||
|
.whereType<String>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final paginationCursor = data['pagination']['cursor'];
|
||||||
|
if (paginationCursor != null && channels.isNotEmpty) {
|
||||||
|
// Throttle between pages to stay under Twitch's rate limit, matching
|
||||||
|
// getUserBlockedList above.
|
||||||
|
await Future.delayed(const Duration(milliseconds: 150));
|
||||||
|
result.addAll(
|
||||||
|
await getModeratedChannels(id: id, cursor: paginationCursor),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
// Backstop: callers should skip this without moderator scopes, but a
|
||||||
|
// stale/partial token must not take down chat.
|
||||||
|
debugPrint('Failed to get moderated channels: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a specific chat message.
|
||||||
|
Future<bool> deleteChatMessage({
|
||||||
|
required String broadcasterId,
|
||||||
|
required String moderatorId,
|
||||||
|
required String messageId,
|
||||||
|
}) async {
|
||||||
|
// Let ApiException propagate so the caller can surface Twitch's reason
|
||||||
|
// (e.g. you can't delete the broadcaster's or another moderator's message)
|
||||||
|
// instead of a generic failure.
|
||||||
|
await delete<dynamic>(
|
||||||
|
'/moderation/chat',
|
||||||
|
queryParameters: {
|
||||||
|
'broadcaster_id': broadcasterId,
|
||||||
|
'moderator_id': moderatorId,
|
||||||
|
'message_id': messageId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bans or times out a user from a channel.
|
||||||
|
///
|
||||||
|
/// The optional `duration` in seconds will timeout the user. If omitted, the user is banned.
|
||||||
|
/// The optional `reason` will be displayed to the banned user and other moderators.
|
||||||
|
Future<bool> banUser({
|
||||||
|
required String broadcasterId,
|
||||||
|
required String moderatorId,
|
||||||
|
required String userIdToBan,
|
||||||
|
int? duration,
|
||||||
|
String? reason,
|
||||||
|
}) async {
|
||||||
|
final requestBody = {
|
||||||
|
'data': {
|
||||||
|
'user_id': userIdToBan,
|
||||||
|
'duration': ?duration,
|
||||||
|
'reason': ?reason,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Let ApiException propagate so the caller can surface Twitch's reason
|
||||||
|
// (e.g. the target is a moderator and can't be banned) instead of a
|
||||||
|
// generic failure.
|
||||||
|
final response = await post<JsonMap>(
|
||||||
|
'/moderation/bans',
|
||||||
|
data: requestBody,
|
||||||
|
queryParameters: {
|
||||||
|
'broadcaster_id': broadcasterId,
|
||||||
|
'moderator_id': moderatorId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final data = response['data'] as JsonList;
|
||||||
|
return data.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a ban or timeout for a user in a channel.
|
||||||
|
///
|
||||||
|
/// A timeout is a time-limited ban, so this lifts both. Lets ApiException
|
||||||
|
/// propagate (e.g. the user isn't currently banned) so the caller can show
|
||||||
|
/// Twitch's reason.
|
||||||
|
Future<bool> unbanUser({
|
||||||
|
required String broadcasterId,
|
||||||
|
required String moderatorId,
|
||||||
|
required String userId,
|
||||||
|
}) async {
|
||||||
|
await delete<dynamic>(
|
||||||
|
'/moderation/bans',
|
||||||
|
queryParameters: {
|
||||||
|
'broadcaster_id': broadcasterId,
|
||||||
|
'moderator_id': moderatorId,
|
||||||
|
'user_id': userId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a successful Twitch `/oauth2/validate` call.
|
||||||
|
class TwitchTokenInfo {
|
||||||
|
final List<String> scopes;
|
||||||
|
|
||||||
|
/// Present for user access tokens; omitted for app access tokens.
|
||||||
|
final String? userId;
|
||||||
|
|
||||||
|
const TwitchTokenInfo({required this.scopes, this.userId});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:frosty/main.dart';
|
import 'package:frosty/main.dart';
|
||||||
import 'package:frosty/screens/onboarding/login_webview.dart';
|
import 'package:frosty/screens/onboarding/login_webview.dart';
|
||||||
import 'package:frosty/screens/settings/stores/auth_store.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:frosty/widgets/frosty_dialog.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
|
@ -14,16 +15,66 @@ class UnauthorizedInterceptor extends Interceptor {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||||
// Check if this is a 401 Unauthorized error
|
// Check if this is a 401 Unauthorized error on a Twitch Helix/OAuth request
|
||||||
if (err.response?.statusCode == 401) {
|
if (err.response?.statusCode == 401 &&
|
||||||
|
_isTwitchHelixOrOAuth(err.requestOptions.uri)) {
|
||||||
// For token validation requests, let the error propagate so validateToken can handle it
|
// For token validation requests, let the error propagate so validateToken can handle it
|
||||||
if (err.requestOptions.uri.path.endsWith('/validate')) {
|
if (err.requestOptions.uri.path.endsWith('/validate')) {
|
||||||
handler.next(err);
|
handler.next(err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For other requests, show login dialog and reject so the Future completes
|
final path = err.requestOptions.uri.path;
|
||||||
_showLoginDialog();
|
|
||||||
|
// 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);
|
handler.reject(err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -32,25 +83,20 @@ class UnauthorizedInterceptor extends Interceptor {
|
||||||
handler.next(err);
|
handler.next(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showLoginDialog() {
|
void _showAuthDialog({
|
||||||
// Use the global navigator key to show dialog without needing BuildContext
|
required String title,
|
||||||
|
required String message,
|
||||||
|
required String primaryLabel,
|
||||||
|
required VoidCallback onPrimary,
|
||||||
|
}) {
|
||||||
final context = navigatorKey.currentContext;
|
final context = navigatorKey.currentContext;
|
||||||
if (context == null) return;
|
if (context == null) return;
|
||||||
|
|
||||||
// Use a flag to prevent multiple dialogs
|
|
||||||
if (_isDialogShowing) return;
|
if (_isDialogShowing) return;
|
||||||
_isDialogShowing = true;
|
_isDialogShowing = true;
|
||||||
|
|
||||||
// Determine if user is logged in but missing scopes vs completely logged out
|
|
||||||
final isLoggedIn = _authStore.isLoggedIn;
|
|
||||||
final title = isLoggedIn ? 'Missing permissions' : 'Session expired';
|
|
||||||
final message = isLoggedIn
|
|
||||||
? 'Your session is missing permissions. Please log in again to continue.'
|
|
||||||
: 'Your session has expired. Please log in again to continue.';
|
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false, // User must choose an action
|
barrierDismissible: false,
|
||||||
builder: (BuildContext dialogContext) {
|
builder: (BuildContext dialogContext) {
|
||||||
return FrostyDialog(
|
return FrostyDialog(
|
||||||
title: title,
|
title: title,
|
||||||
|
|
@ -59,34 +105,39 @@ class UnauthorizedInterceptor extends Interceptor {
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_isDialogShowing = false;
|
_isDialogShowing = false;
|
||||||
Navigator.of(dialogContext).pop(); // Close dialog
|
Navigator.of(dialogContext).pop();
|
||||||
},
|
},
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_isDialogShowing = false;
|
_isDialogShowing = false;
|
||||||
Navigator.of(dialogContext).pop(); // Close dialog
|
Navigator.of(dialogContext).pop();
|
||||||
// Navigate to login WebView
|
onPrimary();
|
||||||
Navigator.of(context).push(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => Provider<AuthStore>.value(
|
|
||||||
value: _authStore,
|
|
||||||
child: const LoginWebView(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
child: Text('Log in'),
|
child: Text(primaryLabel),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
).then((_) {
|
).then((_) {
|
||||||
// Reset flag when dialog is dismissed
|
|
||||||
_isDialogShowing = false;
|
_isDialogShowing = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool _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');
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,18 @@ 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');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,30 @@
|
||||||
import 'dart:async';
|
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:app_links/app_links.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:firebase_analytics/firebase_analytics.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: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/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_auth_interceptor.dart';
|
||||||
|
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||||
import 'package:frosty/apis/unauthorized_interceptor.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/channel/channel.dart';
|
||||||
import 'package:frosty/screens/home/home.dart';
|
import 'package:frosty/screens/home/home.dart';
|
||||||
|
|
@ -33,6 +39,25 @@ 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: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();
|
||||||
|
|
@ -41,17 +66,44 @@ void main() async {
|
||||||
|
|
||||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
await Firebase.initializeApp(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.
|
||||||
|
|
@ -104,6 +156,7 @@ void main() async {
|
||||||
final bttvApiService = BTTVApi(dioClient);
|
final bttvApiService = BTTVApi(dioClient);
|
||||||
final ffzApiService = FFZApi(dioClient);
|
final ffzApiService = FFZApi(dioClient);
|
||||||
final sevenTVApiService = SevenTVApi(dioClient);
|
final sevenTVApiService = SevenTVApi(dioClient);
|
||||||
|
final twitchGqlApiService = TwitchGqlApi(dioClient);
|
||||||
|
|
||||||
// Create global assets store (shared cache for global emotes/badges)
|
// Create global assets store (shared cache for global emotes/badges)
|
||||||
final globalAssetsStore = GlobalAssetsStore(
|
final globalAssetsStore = GlobalAssetsStore(
|
||||||
|
|
@ -114,7 +167,10 @@ void main() async {
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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
|
// Add the auth interceptor to the Dio client after AuthStore creation
|
||||||
dioClient.interceptors.add(TwitchAuthInterceptor(authStore));
|
dioClient.interceptors.add(TwitchAuthInterceptor(authStore));
|
||||||
|
|
@ -137,6 +193,7 @@ void main() async {
|
||||||
Provider<BTTVApi>.value(value: bttvApiService),
|
Provider<BTTVApi>.value(value: bttvApiService),
|
||||||
Provider<FFZApi>.value(value: ffzApiService),
|
Provider<FFZApi>.value(value: ffzApiService),
|
||||||
Provider<SevenTVApi>.value(value: sevenTVApiService),
|
Provider<SevenTVApi>.value(value: sevenTVApiService),
|
||||||
|
Provider<TwitchGqlApi>.value(value: twitchGqlApiService),
|
||||||
Provider<GlobalAssetsStore>.value(value: globalAssetsStore),
|
Provider<GlobalAssetsStore>.value(value: globalAssetsStore),
|
||||||
],
|
],
|
||||||
child: MyApp(firstRun: firstRun),
|
child: MyApp(firstRun: firstRun),
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,11 @@ class IRCMessage {
|
||||||
this.actionLabel,
|
this.actionLabel,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// The channel this message actually originated from. In Twitch shared
|
||||||
|
/// chat, `source-room-id` names the participant channel a message was
|
||||||
|
/// sent in; otherwise it's just this connection's own room.
|
||||||
|
String? get sourceChannelId => tags['source-room-id'] ?? tags['room-id'];
|
||||||
|
|
||||||
/// Applies the given CLEARCHAT message to the provided messages and buffer.
|
/// Applies the given CLEARCHAT message to the provided messages and buffer.
|
||||||
static void clearChat({
|
static void clearChat({
|
||||||
required List<IRCMessage> messages,
|
required List<IRCMessage> messages,
|
||||||
|
|
@ -176,7 +181,9 @@ class IRCMessage {
|
||||||
size: badgeSize,
|
size: badgeSize,
|
||||||
color:
|
color:
|
||||||
Theme.of(context).iconTheme.color?.withValues(alpha: 0.5) ??
|
Theme.of(context).iconTheme.color?.withValues(alpha: 0.5) ??
|
||||||
Colors.grey.withValues(alpha: 0.5),
|
Theme.of(context).colorScheme.onSurfaceVariant.withValues(
|
||||||
|
alpha: 0.5,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -184,7 +191,6 @@ class IRCMessage {
|
||||||
span.add(const TextSpan(text: ' '));
|
span.add(const TextSpan(text: ' '));
|
||||||
}
|
}
|
||||||
|
|
||||||
final sourceChannelId = tags['source-room-id'] ?? tags['room-id'];
|
|
||||||
final sourceChannelUser = channelIdToUserTwitch?[sourceChannelId];
|
final sourceChannelUser = channelIdToUserTwitch?[sourceChannelId];
|
||||||
if (sourceChannelUser != null) {
|
if (sourceChannelUser != null) {
|
||||||
final isCurrentChannel = sourceChannelId == currentChannelId;
|
final isCurrentChannel = sourceChannelId == currentChannelId;
|
||||||
|
|
@ -661,6 +667,8 @@ class IRCMessage {
|
||||||
bool showMessage = true,
|
bool showMessage = true,
|
||||||
Map<String, UserTwitch>? channelIdToUserTwitch,
|
Map<String, UserTwitch>? channelIdToUserTwitch,
|
||||||
TimestampType timestamp = TimestampType.disabled,
|
TimestampType timestamp = TimestampType.disabled,
|
||||||
|
bool forceTimestamp = false,
|
||||||
|
bool showHistoricalTimestamps = false,
|
||||||
String? currentChannelId,
|
String? currentChannelId,
|
||||||
}) {
|
}) {
|
||||||
final emoteToObject = assetsStore.emoteToObject;
|
final emoteToObject = assetsStore.emoteToObject;
|
||||||
|
|
@ -671,7 +679,19 @@ class IRCMessage {
|
||||||
// The span list that will be used to render the chat message
|
// The span list that will be used to render the chat message
|
||||||
final span = <InlineSpan>[];
|
final span = <InlineSpan>[];
|
||||||
|
|
||||||
_addTimestamp(span, style, timestamp);
|
// When the user has timestamps disabled globally, fall back to the device's
|
||||||
|
// locale preference for historical messages (opt-in via setting) and for
|
||||||
|
// forced contexts like previews.
|
||||||
|
final isHistorical = tags['historical'] == '1';
|
||||||
|
final effectiveTimestamp =
|
||||||
|
(timestamp == TimestampType.disabled &&
|
||||||
|
((isHistorical && showHistoricalTimestamps) || forceTimestamp))
|
||||||
|
? (MediaQuery.alwaysUse24HourFormatOf(context)
|
||||||
|
? TimestampType.twentyFour
|
||||||
|
: TimestampType.twelve)
|
||||||
|
: timestamp;
|
||||||
|
|
||||||
|
_addTimestamp(span, style, effectiveTimestamp);
|
||||||
_addHistoricalAndChannelBadges(
|
_addHistoricalAndChannelBadges(
|
||||||
context,
|
context,
|
||||||
span,
|
span,
|
||||||
|
|
@ -680,7 +700,6 @@ class IRCMessage {
|
||||||
currentChannelId,
|
currentChannelId,
|
||||||
);
|
);
|
||||||
|
|
||||||
final isHistorical = tags['historical'] == '1';
|
|
||||||
_addUserBadges(
|
_addUserBadges(
|
||||||
context,
|
context,
|
||||||
span,
|
span,
|
||||||
|
|
@ -959,22 +978,31 @@ class IRCMessage {
|
||||||
// Obtain the index to break off the tags.
|
// Obtain the index to break off the tags.
|
||||||
final tagAndIrcMessageDivider = whole.indexOf(' ');
|
final tagAndIrcMessageDivider = whole.indexOf(' ');
|
||||||
|
|
||||||
// Get the tags substring and escape characters.
|
// Without a separating space there are no tags to slice and no message body
|
||||||
// IRC messages escape spaces with \s.
|
// to parse — bail out rather than throw on the substring/index calls below.
|
||||||
final tags = whole
|
if (tagAndIrcMessageDivider == -1) {
|
||||||
.substring(1, tagAndIrcMessageDivider)
|
return IRCMessage(raw: whole, command: Command.none, tags: const {});
|
||||||
.replaceAll('\\s', ' ');
|
}
|
||||||
|
|
||||||
// Next, parse and map the tags.
|
// Parse the IRCv3 tags into a map. The tags section is everything between
|
||||||
|
// the leading '@' and the first space. We split on the *raw* string: a
|
||||||
|
// literal ';' is always a separator because escaped semicolons inside a
|
||||||
|
// value are encoded as `\:` (unescaped per-value below).
|
||||||
final mappedTags = <String, String>{};
|
final mappedTags = <String, String>{};
|
||||||
|
final rawTags = whole.substring(1, tagAndIrcMessageDivider);
|
||||||
|
|
||||||
// Loop through each tag and store their key value pairs into the map.
|
for (final tag in rawTags.split(';')) {
|
||||||
for (final tag in tags.split(';')) {
|
// Split on the first '=' only — IRCv3 tag values may contain '='.
|
||||||
// Skip if the tag has no value.
|
final equalsIndex = tag.indexOf('=');
|
||||||
if (tag.endsWith('=')) continue;
|
|
||||||
|
|
||||||
final tagSplit = tag.split('=');
|
// Skip valueless tags. Per the IRCv3 spec a missing value (bare `key`)
|
||||||
mappedTags[tagSplit[0]] = tagSplit[1];
|
// and an empty value (`key=`) are equivalent — live Twitch sends the
|
||||||
|
// latter, the recent-messages API the former.
|
||||||
|
if (equalsIndex == -1 || equalsIndex == tag.length - 1) continue;
|
||||||
|
|
||||||
|
mappedTags[tag.substring(0, equalsIndex)] = _unescapeTagValue(
|
||||||
|
tag.substring(equalsIndex + 1),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now we'll parse the message itself.
|
// Now we'll parse the message itself.
|
||||||
|
|
@ -988,11 +1016,29 @@ class IRCMessage {
|
||||||
// Index 3 and beyond is the :message (word by word) that is sent by the user or empty depending on the command
|
// Index 3 and beyond is the :message (word by word) that is sent by the user or empty depending on the command
|
||||||
final splitMessage = ircMessage.split(' ');
|
final splitMessage = ircMessage.split(' ');
|
||||||
|
|
||||||
|
// A well-formed message is at least `<prefix> <COMMAND>`. A malformed or
|
||||||
|
// truncated line (e.g. from the recent-messages history API) can split into
|
||||||
|
// a single token, which would otherwise throw a RangeError on the
|
||||||
|
// splitMessage[0]/[1] accesses below.
|
||||||
|
if (splitMessage.length < 2) {
|
||||||
|
return IRCMessage(raw: whole, command: Command.none, tags: mappedTags);
|
||||||
|
}
|
||||||
|
|
||||||
// If the username exists, set it.
|
// If the username exists, set it.
|
||||||
// tmi.twitch.tv means the message was sent by Twitch rather than a user, so will be irrelevant.
|
// tmi.twitch.tv means the message was sent by Twitch rather than a user, so it is irrelevant.
|
||||||
final String? user = splitMessage[0] == _twitchIrcServer
|
final prefix = splitMessage[0];
|
||||||
? mappedTags['login']
|
final String? user;
|
||||||
: splitMessage[0].substring(0, splitMessage[0].indexOf('!'));
|
if (prefix == _twitchIrcServer) {
|
||||||
|
user = mappedTags['login'];
|
||||||
|
} else {
|
||||||
|
// Normal prefixes are `nick!user@host`. Fall back to the login tag (or
|
||||||
|
// null) if the expected `!` is absent so an unexpected prefix shape can
|
||||||
|
// never throw a RangeError and take down the whole message.
|
||||||
|
final bangIndex = prefix.indexOf('!');
|
||||||
|
user = bangIndex == -1
|
||||||
|
? mappedTags['login']
|
||||||
|
: prefix.substring(0, bangIndex);
|
||||||
|
}
|
||||||
|
|
||||||
// If there is an associated message, set it.
|
// If there is an associated message, set it.
|
||||||
String? message;
|
String? message;
|
||||||
|
|
@ -1023,9 +1069,9 @@ class IRCMessage {
|
||||||
indexBetweenIdAndPositions,
|
indexBetweenIdAndPositions,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Parse the range in order to extract the associated word.
|
// Parse the range to extract the associated word.
|
||||||
// If there are more than one indices, use the first one.
|
// If there is more than one index, use the first.
|
||||||
// Else, use the one provided indices.
|
// Otherwise, use the provided index.
|
||||||
final String range;
|
final String range;
|
||||||
if (emoteIdAndPosition.contains(',')) {
|
if (emoteIdAndPosition.contains(',')) {
|
||||||
range = emoteIdAndPosition.substring(
|
range = emoteIdAndPosition.substring(
|
||||||
|
|
@ -1153,6 +1199,50 @@ class IRCMessage {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Unescapes an IRCv3 message-tag value.
|
||||||
|
///
|
||||||
|
/// The spec defines these sequences: `\:` → `;`, `\s` → space, `\\` → `\`,
|
||||||
|
/// `\r` → CR, `\n` → LF. Any other `\x` collapses to `x`, and a lone trailing
|
||||||
|
/// backslash is dropped. Done in a single pass so sequences can't be
|
||||||
|
/// double-unescaped (e.g. `\\s` correctly yields `\s`, not a space).
|
||||||
|
static String _unescapeTagValue(String value) {
|
||||||
|
// Fast path: the vast majority of tag values contain no escape sequences.
|
||||||
|
if (!value.contains('\\')) return value;
|
||||||
|
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
for (var i = 0; i < value.length; i++) {
|
||||||
|
if (value[i] != '\\') {
|
||||||
|
buffer.write(value[i]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A trailing backslash with nothing after it is dropped.
|
||||||
|
if (i + 1 == value.length) break;
|
||||||
|
|
||||||
|
switch (value[i + 1]) {
|
||||||
|
case ':':
|
||||||
|
buffer.write(';');
|
||||||
|
break;
|
||||||
|
case 's':
|
||||||
|
buffer.write(' ');
|
||||||
|
break;
|
||||||
|
case '\\':
|
||||||
|
buffer.write('\\');
|
||||||
|
break;
|
||||||
|
case 'r':
|
||||||
|
buffer.write('\r');
|
||||||
|
break;
|
||||||
|
case 'n':
|
||||||
|
buffer.write('\n');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
buffer.write(value[i + 1]);
|
||||||
|
}
|
||||||
|
i++; // Skip the escaped character we just consumed.
|
||||||
|
}
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
factory IRCMessage.createNotice({
|
factory IRCMessage.createNotice({
|
||||||
required String message,
|
required String message,
|
||||||
VoidCallback? actionCallback,
|
VoidCallback? actionCallback,
|
||||||
|
|
@ -1214,8 +1304,9 @@ class USERSTATE {
|
||||||
raw: ircMessage.raw,
|
raw: ircMessage.raw,
|
||||||
color: ircMessage.tags['color'] ?? color,
|
color: ircMessage.tags['color'] ?? color,
|
||||||
displayName: ircMessage.tags['display-name'] ?? displayName,
|
displayName: ircMessage.tags['display-name'] ?? displayName,
|
||||||
mod: ircMessage.tags['mod'] == '0' ? false : true,
|
// Missing tags must be false — the old `!= '0'` default treated absence as mod.
|
||||||
subscriber: ircMessage.tags['subscriber'] == '0' ? false : true,
|
mod: ircMessage.tags['mod'] == '1',
|
||||||
|
subscriber: ircMessage.tags['subscriber'] == '1',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -38,6 +48,20 @@ class StreamsTwitch {
|
||||||
|
|
||||||
const StreamsTwitch(this.data, this.pagination);
|
const StreamsTwitch(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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ StreamTwitch _$StreamTwitchFromJson(Map<String, dynamic> json) => StreamTwitch(
|
||||||
(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) =>
|
||||||
|
|
|
||||||
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();
|
||||||
|
}
|
||||||
|
|
@ -6,9 +6,13 @@ import 'package:flutter_mobx/flutter_mobx.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/stores/chat_tabs_store.dart';
|
||||||
import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart';
|
import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/channel_info_header.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/native_video.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/native_video_store.dart';
|
||||||
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
|
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
|
||||||
import 'package:frosty/screens/channel/video/video.dart';
|
import 'package:frosty/screens/channel/video/video.dart';
|
||||||
import 'package:frosty/screens/channel/video/video_overlay.dart';
|
import 'package:frosty/screens/channel/video/video_overlay.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/video_player_interface.dart';
|
||||||
import 'package:frosty/screens/channel/video/video_store.dart';
|
import 'package:frosty/screens/channel/video/video_store.dart';
|
||||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||||
import 'package:frosty/theme.dart';
|
import 'package:frosty/theme.dart';
|
||||||
|
|
@ -43,11 +47,10 @@ class _VideoChatState extends State<VideoChat>
|
||||||
final _videoKey = GlobalKey();
|
final _videoKey = GlobalKey();
|
||||||
final _chatKey = GlobalKey();
|
final _chatKey = GlobalKey();
|
||||||
|
|
||||||
// PiP drag state - essential only
|
// PiP drag state — ValueNotifier avoids full widget rebuilds during drag.
|
||||||
double _pipDragDistance = 0;
|
final _pipDragDistance = ValueNotifier<double>(0);
|
||||||
bool _isPipDragging = false;
|
bool _isPipDragging = false;
|
||||||
bool _isInPipTriggerZone =
|
bool _isInPipTriggerZone = false;
|
||||||
false; // Track when in trigger zone for haptic feedback
|
|
||||||
|
|
||||||
// Divider drag state for synchronizing animation
|
// Divider drag state for synchronizing animation
|
||||||
bool _isDividerDragging = false;
|
bool _isDividerDragging = false;
|
||||||
|
|
@ -75,13 +78,24 @@ class _VideoChatState extends State<VideoChat>
|
||||||
primaryDisplayName: widget.userName,
|
primaryDisplayName: widget.userName,
|
||||||
);
|
);
|
||||||
|
|
||||||
late final VideoStore _videoStore = VideoStore(
|
late final VideoPlayerInterface _videoStore =
|
||||||
userLogin: widget.userLogin,
|
context.settingsStore.useNativePlayer
|
||||||
userId: widget.userId,
|
? NativeVideoStore(
|
||||||
twitchApi: context.twitchApi,
|
userLogin: widget.userLogin,
|
||||||
authStore: context.authStore,
|
userId: widget.userId,
|
||||||
settingsStore: context.settingsStore,
|
displayName: widget.userName,
|
||||||
);
|
twitchApi: context.twitchApi,
|
||||||
|
twitchGqlApi: context.twitchGqlApi,
|
||||||
|
authStore: context.authStore,
|
||||||
|
settingsStore: context.settingsStore,
|
||||||
|
)
|
||||||
|
: VideoStore(
|
||||||
|
userLogin: widget.userLogin,
|
||||||
|
userId: widget.userId,
|
||||||
|
twitchApi: context.twitchApi,
|
||||||
|
authStore: context.authStore,
|
||||||
|
settingsStore: context.settingsStore,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -98,12 +112,10 @@ class _VideoChatState extends State<VideoChat>
|
||||||
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
|
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Attach listener to the controller (not the derived animation) so it
|
// Drive the ValueNotifier from the spring-back animation so the
|
||||||
// survives _springBackAnimation being reassigned in _animateSpringBack.
|
// AnimatedBuilder rebuilds without a full setState.
|
||||||
_animationController.addListener(() {
|
_animationController.addListener(() {
|
||||||
setState(() {
|
_pipDragDistance.value = _springBackAnimation.value;
|
||||||
_pipDragDistance = _springBackAnimation.value;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register as observer for app lifecycle events
|
// Register as observer for app lifecycle events
|
||||||
|
|
@ -120,58 +132,58 @@ class _VideoChatState extends State<VideoChat>
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handlePipDragStart(DragStartDetails details) {
|
void _handlePipDragStart(DragStartDetails details) {
|
||||||
// Disable drag gesture when already in PiP mode or video is not playing
|
if (_videoStore.isInPipMode || _videoStore.paused || _videoStore.loading) {
|
||||||
if (_videoStore.isInPipMode || _videoStore.paused) return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_animationController.stop(); // Stop any ongoing animation
|
_animationController.stop();
|
||||||
|
_pipDragDistance.value = 0;
|
||||||
|
_isInPipTriggerZone = false;
|
||||||
setState(() {
|
setState(() {
|
||||||
_isPipDragging = true;
|
_isPipDragging = true;
|
||||||
_pipDragDistance = 0;
|
|
||||||
_isInPipTriggerZone = false;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handlePipDragUpdate(DragUpdateDetails details) {
|
void _handlePipDragUpdate(DragUpdateDetails details) {
|
||||||
if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) {
|
if (!_isPipDragging ||
|
||||||
|
_videoStore.isInPipMode ||
|
||||||
|
_videoStore.paused ||
|
||||||
|
_videoStore.loading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
// Update the ValueNotifier directly — only the ListenableBuilder rebuilds,
|
||||||
_pipDragDistance += details.delta.dy;
|
// not the entire channel scaffold.
|
||||||
_pipDragDistance = _pipDragDistance.clamp(0, _pipMaxDragDistance);
|
_pipDragDistance.value =
|
||||||
|
(_pipDragDistance.value + details.delta.dy).clamp(0, _pipMaxDragDistance);
|
||||||
|
|
||||||
// Check if we've entered or exited the trigger zone for haptic feedback
|
final wasInTriggerZone = _isInPipTriggerZone;
|
||||||
final wasInTriggerZone = _isInPipTriggerZone;
|
_isInPipTriggerZone = _pipDragDistance.value >= _pipTriggerDistance;
|
||||||
_isInPipTriggerZone = _pipDragDistance >= _pipTriggerDistance;
|
|
||||||
|
|
||||||
// Provide haptic feedback when entering the trigger zone
|
if (!wasInTriggerZone && _isInPipTriggerZone) {
|
||||||
if (!wasInTriggerZone && _isInPipTriggerZone) {
|
HapticFeedback.mediumImpact();
|
||||||
HapticFeedback.mediumImpact(); // Entering trigger zone
|
} else if (wasInTriggerZone && !_isInPipTriggerZone) {
|
||||||
}
|
HapticFeedback.lightImpact();
|
||||||
// Provide subtle haptic feedback when exiting the trigger zone
|
}
|
||||||
else if (wasInTriggerZone && !_isInPipTriggerZone) {
|
|
||||||
HapticFeedback.lightImpact(); // Exiting trigger zone
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handlePipDragEnd(DragEndDetails details) {
|
void _handlePipDragEnd(DragEndDetails details) {
|
||||||
if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) {
|
if (!_isPipDragging ||
|
||||||
|
_videoStore.isInPipMode ||
|
||||||
|
_videoStore.paused ||
|
||||||
|
_videoStore.loading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final velocity = details.velocity.pixelsPerSecond.dy;
|
final velocity = details.velocity.pixelsPerSecond.dy;
|
||||||
final shouldTriggerPip =
|
final shouldTriggerPip =
|
||||||
_pipDragDistance >= _pipTriggerDistance ||
|
_pipDragDistance.value >= _pipTriggerDistance || velocity > 600;
|
||||||
velocity > 600; // Simple velocity threshold
|
|
||||||
|
|
||||||
if (shouldTriggerPip) {
|
if (shouldTriggerPip) {
|
||||||
// Simple haptic feedback on success
|
|
||||||
HapticFeedback.mediumImpact();
|
HapticFeedback.mediumImpact();
|
||||||
_videoStore.requestPictureInPicture();
|
_videoStore.requestPictureInPicture();
|
||||||
_resetDragState();
|
_resetDragState();
|
||||||
} else {
|
} else {
|
||||||
// Animate back to original position
|
|
||||||
_animateSpringBack();
|
_animateSpringBack();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -182,16 +194,16 @@ class _VideoChatState extends State<VideoChat>
|
||||||
}
|
}
|
||||||
|
|
||||||
void _resetDragState() {
|
void _resetDragState() {
|
||||||
|
_pipDragDistance.value = 0;
|
||||||
|
_isInPipTriggerZone = false;
|
||||||
setState(() {
|
setState(() {
|
||||||
_isPipDragging = false;
|
_isPipDragging = false;
|
||||||
_pipDragDistance = 0;
|
|
||||||
_isInPipTriggerZone = false;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _animateSpringBack() {
|
void _animateSpringBack() {
|
||||||
_springBackAnimation = Tween<double>(begin: _pipDragDistance, end: 0)
|
_springBackAnimation =
|
||||||
.animate(
|
Tween<double>(begin: _pipDragDistance.value, end: 0).animate(
|
||||||
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
|
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -229,13 +241,13 @@ class _VideoChatState extends State<VideoChat>
|
||||||
? AspectRatio(aspectRatio: aspectRatio, child: child)
|
? AspectRatio(aspectRatio: aspectRatio, child: child)
|
||||||
: child;
|
: child;
|
||||||
|
|
||||||
return AnimatedBuilder(
|
// Listen to the ValueNotifier (drag) and AnimationController (spring-back)
|
||||||
animation: _animationController,
|
// so only this subtree rebuilds during the gesture — not the full scaffold.
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: _pipDragDistance,
|
||||||
child: wrappedChild,
|
child: wrappedChild,
|
||||||
builder: (context, animatedChild) {
|
builder: (context, animatedChild) {
|
||||||
final currentDragDistance = _isPipDragging
|
final currentDragDistance = _pipDragDistance.value;
|
||||||
? _pipDragDistance
|
|
||||||
: _springBackAnimation.value;
|
|
||||||
|
|
||||||
final scaleFactor =
|
final scaleFactor =
|
||||||
1.0 - (currentDragDistance / _pipMaxDragDistance * 0.1);
|
1.0 - (currentDragDistance / _pipMaxDragDistance * 0.1);
|
||||||
|
|
@ -256,11 +268,13 @@ class _VideoChatState extends State<VideoChat>
|
||||||
if (!_videoStore.isInPipMode)
|
if (!_videoStore.isInPipMode)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
ignoring: !(_isPipDragging && _pipDragDistance > 0),
|
ignoring:
|
||||||
|
!(_isPipDragging && currentDragDistance > 0),
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
opacity: (_isPipDragging && _pipDragDistance > 0)
|
opacity:
|
||||||
? 1.0
|
(_isPipDragging && currentDragDistance > 0)
|
||||||
: 0.0,
|
? 1.0
|
||||||
|
: 0.0,
|
||||||
duration: const Duration(milliseconds: 150),
|
duration: const Duration(milliseconds: 150),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
child: Container(
|
child: Container(
|
||||||
|
|
@ -297,7 +311,19 @@ class _VideoChatState extends State<VideoChat>
|
||||||
|
|
||||||
final player = GestureDetector(
|
final player = GestureDetector(
|
||||||
onLongPress: _videoStore.handleToggleOverlay,
|
onLongPress: _videoStore.handleToggleOverlay,
|
||||||
child: Video(key: _videoKey, videoStore: _videoStore),
|
child: _videoStore is NativeVideoStore
|
||||||
|
? NativeVideo(
|
||||||
|
// GlobalKey so the native platform view is *reparented* (not
|
||||||
|
// rebuilt) across the portrait↔landscape layout swap — without
|
||||||
|
// it, rotating destroys and recreates the player, forcing a
|
||||||
|
// reload. Mirrors the WebView Video below.
|
||||||
|
key: _videoKey,
|
||||||
|
nativeVideoStore: _videoStore,
|
||||||
|
)
|
||||||
|
: Video(
|
||||||
|
key: _videoKey,
|
||||||
|
videoStore: _videoStore as VideoStore,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final overlay = GestureDetector(
|
final overlay = GestureDetector(
|
||||||
|
|
@ -324,7 +350,9 @@ class _VideoChatState extends State<VideoChat>
|
||||||
settingsStore: settingsStore,
|
settingsStore: settingsStore,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (_videoStore.paused || _videoStore.streamInfo == null) {
|
if (_videoStore.paused ||
|
||||||
|
_videoStore.streamInfo == null ||
|
||||||
|
_videoStore.loading) {
|
||||||
return videoOverlay;
|
return videoOverlay;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -344,15 +372,49 @@ class _VideoChatState extends State<VideoChat>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final video = Observer(
|
final video = Stack(
|
||||||
builder: (context) {
|
children: [
|
||||||
return Stack(
|
player,
|
||||||
children: [
|
Positioned.fill(
|
||||||
player,
|
child: Observer(
|
||||||
if (_videoStore.settingsStore.showOverlay) overlay,
|
builder: (_) => AnimatedOpacity(
|
||||||
],
|
opacity: _videoStore.loading ? 1.0 : 0.0,
|
||||||
);
|
// Appear instantly to cover native player placeholders during
|
||||||
},
|
// recovery; fade out smoothly when playback resumes.
|
||||||
|
duration: _videoStore.loading
|
||||||
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
// TickerMode stops the spinner's repeating animation while
|
||||||
|
// hidden — opacity 0 skips painting but would otherwise keep
|
||||||
|
// scheduling frames for the whole video session.
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: TickerMode(
|
||||||
|
enabled: _videoStore.loading,
|
||||||
|
child: const ColoredBox(
|
||||||
|
color: Colors.black,
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Colors.white38,
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Observer(
|
||||||
|
builder: (_) => _videoStore.settingsStore.showOverlay
|
||||||
|
? overlay
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
final chat = Observer(
|
final chat = Observer(
|
||||||
|
|
@ -683,7 +745,15 @@ class _VideoChatState extends State<VideoChat>
|
||||||
if (settingsStore.showVideo) ...[
|
if (settingsStore.showVideo) ...[
|
||||||
AspectRatio(
|
AspectRatio(
|
||||||
aspectRatio: 16 / 9,
|
aspectRatio: 16 / 9,
|
||||||
child: Container(), // Placeholder for video space
|
child: SizedBox(),
|
||||||
|
),
|
||||||
|
ChannelInfoHeader(
|
||||||
|
videoStore: _videoStore,
|
||||||
|
authStore: context.authStore,
|
||||||
|
userId: widget.userId,
|
||||||
|
displayName: widget.userName,
|
||||||
|
isInSharedChatMode:
|
||||||
|
_chatStore.isInSharedChatMode,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
Expanded(child: chat),
|
Expanded(child: chat),
|
||||||
|
|
@ -710,9 +780,16 @@ class _VideoChatState extends State<VideoChat>
|
||||||
|
|
||||||
// If on Android, use PiPSwitcher to enable PiP functionality.
|
// If on Android, use PiPSwitcher to enable PiP functionality.
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
|
// Both players need this — the WebView store used to be skipped by a
|
||||||
|
// NativeVideoPlayerInterface type check, so it never learned it was in
|
||||||
|
// PiP on Android.
|
||||||
|
void notifyPip(bool isInPip) => _videoStore.handleAndroidPipChanged(isInPip);
|
||||||
|
|
||||||
return PipWidget(
|
return PipWidget(
|
||||||
pipLayout: PipActionsLayout.mediaOnlyPause,
|
pipLayout: PipActionsLayout.mediaOnlyPause,
|
||||||
onPipAction: (_) => _videoStore.handlePausePlay(),
|
onPipAction: (_) => _videoStore.handlePausePlay(),
|
||||||
|
onPipEntered: () => notifyPip(true),
|
||||||
|
onPipExited: () => notifyPip(false),
|
||||||
pipChild: player,
|
pipChild: player,
|
||||||
child: videoChat,
|
child: videoChat,
|
||||||
);
|
);
|
||||||
|
|
@ -731,6 +808,7 @@ class _VideoChatState extends State<VideoChat>
|
||||||
_videoStore.dispose();
|
_videoStore.dispose();
|
||||||
|
|
||||||
_animationController.dispose();
|
_animationController.dispose();
|
||||||
|
_pipDragDistance.dispose();
|
||||||
|
|
||||||
SystemChrome.setEnabledSystemUIMode(
|
SystemChrome.setEnabledSystemUIMode(
|
||||||
SystemUiMode.manual,
|
SystemUiMode.manual,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
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';
|
||||||
|
|
@ -11,6 +12,10 @@ import 'package:frosty/utils/context_extensions.dart';
|
||||||
import 'package:frosty/widgets/frosty_page_view.dart';
|
import 'package:frosty/widgets/frosty_page_view.dart';
|
||||||
import 'package:frosty/widgets/frosty_scrollbar.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;
|
final EdgeInsetsGeometry? listPadding;
|
||||||
|
|
@ -81,25 +86,59 @@ class Chat extends StatelessWidget {
|
||||||
? chatTabsStore!.mergedScrollController
|
? chatTabsStore!.mergedScrollController
|
||||||
: chatStore.scrollController;
|
: chatStore.scrollController;
|
||||||
|
|
||||||
return FrostyScrollbar(
|
// Freeze the message list while a finger rests on a
|
||||||
controller: scrollController,
|
// message so a long-press target can't scroll out
|
||||||
padding: EdgeInsets.only(
|
// from under it (which would dispose its recognizer
|
||||||
top: MediaQuery.of(context).padding.top,
|
// mid-press). The store resumes flowing as soon as
|
||||||
bottom:
|
// the pointer moves like a scroll, so scrolling is
|
||||||
chatStore.bottomBarHeight + bottomPadding,
|
// never interrupted — only a stationary hold pauses.
|
||||||
),
|
void onDown(PointerDownEvent e) => _isMerged
|
||||||
child: Observer(
|
? chatTabsStore!.onMessageListPointerDown(
|
||||||
builder: (context) {
|
e.pointer,
|
||||||
return _isMerged
|
e.position,
|
||||||
? _buildMergedList(
|
)
|
||||||
scrollController,
|
: chatStore.onMessageListPointerDown(
|
||||||
bottomPadding,
|
e.pointer,
|
||||||
)
|
e.position,
|
||||||
: _buildNormalList(
|
);
|
||||||
scrollController,
|
void onMove(PointerMoveEvent e) => _isMerged
|
||||||
bottomPadding,
|
? chatTabsStore!.onMessageListPointerMove(
|
||||||
);
|
e.pointer,
|
||||||
},
|
e.position,
|
||||||
|
)
|
||||||
|
: chatStore.onMessageListPointerMove(
|
||||||
|
e.pointer,
|
||||||
|
e.position,
|
||||||
|
);
|
||||||
|
void onUp(int pointer) => _isMerged
|
||||||
|
? chatTabsStore!.onMessageListPointerUp(pointer)
|
||||||
|
: chatStore.onMessageListPointerUp(pointer);
|
||||||
|
|
||||||
|
return Listener(
|
||||||
|
onPointerDown: onDown,
|
||||||
|
onPointerMove: onMove,
|
||||||
|
onPointerUp: (e) => onUp(e.pointer),
|
||||||
|
onPointerCancel: (e) => onUp(e.pointer),
|
||||||
|
child: FrostyScrollbar(
|
||||||
|
controller: scrollController,
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
top: MediaQuery.of(context).padding.top,
|
||||||
|
bottom:
|
||||||
|
chatStore.bottomBarHeight + bottomPadding,
|
||||||
|
),
|
||||||
|
child: Observer(
|
||||||
|
builder: (context) {
|
||||||
|
return _isMerged
|
||||||
|
? _buildMergedList(
|
||||||
|
scrollController,
|
||||||
|
bottomPadding,
|
||||||
|
)
|
||||||
|
: _buildNormalList(
|
||||||
|
scrollController,
|
||||||
|
bottomPadding,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -140,18 +179,25 @@ class Chat extends StatelessWidget {
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
reverse: true,
|
reverse: true,
|
||||||
padding: (listPadding ?? EdgeInsets.zero).add(
|
padding: (listPadding ?? EdgeInsets.zero).add(
|
||||||
EdgeInsets.only(
|
EdgeInsets.only(bottom: chatStore.bottomBarHeight + bottomPadding),
|
||||||
bottom: chatStore.bottomBarHeight + bottomPadding,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
addAutomaticKeepAlives: false,
|
addAutomaticKeepAlives: false,
|
||||||
|
scrollCacheExtent: _chatCacheExtent,
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
itemCount: chatStore.renderMessages.length,
|
itemCount: chatStore.renderMessages.length,
|
||||||
itemBuilder: (context, index) => ChatMessage(
|
itemBuilder: (context, index) {
|
||||||
ircMessage: chatStore.renderMessages[
|
final ircMessage = chatStore
|
||||||
chatStore.renderMessages.length - 1 - index],
|
.renderMessages[chatStore.renderMessages.length - 1 - index];
|
||||||
chatStore: chatStore,
|
// 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,
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,24 +206,22 @@ class Chat extends StatelessWidget {
|
||||||
double bottomPadding,
|
double bottomPadding,
|
||||||
) {
|
) {
|
||||||
final mergedMessages = chatTabsStore!.mergedMessages;
|
final mergedMessages = chatTabsStore!.mergedMessages;
|
||||||
final channelIdToUserTwitch =
|
final channelIdToUserTwitch = chatTabsStore!.mergedChannelIdToUserTwitch;
|
||||||
chatTabsStore!.mergedChannelIdToUserTwitch;
|
|
||||||
final currentChannelId = chatTabsStore!.activeTab.channelId;
|
final currentChannelId = chatTabsStore!.activeTab.channelId;
|
||||||
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
reverse: true,
|
reverse: true,
|
||||||
padding: (listPadding ?? EdgeInsets.zero).add(
|
padding: (listPadding ?? EdgeInsets.zero).add(
|
||||||
EdgeInsets.only(
|
EdgeInsets.only(bottom: chatStore.bottomBarHeight + bottomPadding),
|
||||||
bottom: chatStore.bottomBarHeight + bottomPadding,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
addAutomaticKeepAlives: false,
|
addAutomaticKeepAlives: false,
|
||||||
|
scrollCacheExtent: _chatCacheExtent,
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
itemCount: mergedMessages.length,
|
itemCount: mergedMessages.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final merged =
|
final merged = mergedMessages[mergedMessages.length - 1 - index];
|
||||||
mergedMessages[mergedMessages.length - 1 - index];
|
|
||||||
return ChatMessage(
|
return ChatMessage(
|
||||||
|
key: ObjectKey(merged.ircMessage),
|
||||||
ircMessage: merged.ircMessage,
|
ircMessage: merged.ircMessage,
|
||||||
chatStore: merged.chatStore,
|
chatStore: merged.chatStore,
|
||||||
inputChatStore: chatStore,
|
inputChatStore: chatStore,
|
||||||
|
|
@ -211,8 +255,7 @@ class Chat extends StatelessWidget {
|
||||||
right: 4,
|
right: 4,
|
||||||
bottom:
|
bottom:
|
||||||
chatStore.bottomBarHeight +
|
chatStore.bottomBarHeight +
|
||||||
(chatStore.assetsStore.showEmoteMenu ||
|
(chatStore.assetsStore.showEmoteMenu || isHorizontalLandscape
|
||||||
isHorizontalLandscape
|
|
||||||
? 0
|
? 0
|
||||||
: MediaQuery.of(context).padding.bottom),
|
: MediaQuery.of(context).padding.bottom),
|
||||||
),
|
),
|
||||||
|
|
@ -236,17 +279,13 @@ class Chat extends StatelessWidget {
|
||||||
? null
|
? null
|
||||||
: ElevatedButton.icon(
|
: ElevatedButton.icon(
|
||||||
onPressed: onResume,
|
onPressed: onResume,
|
||||||
icon: const Icon(
|
icon: const Icon(Icons.arrow_downward_rounded),
|
||||||
Icons.arrow_downward_rounded,
|
|
||||||
),
|
|
||||||
label: Text(
|
label: Text(
|
||||||
bufferCount > 0
|
bufferCount > 0
|
||||||
? '$bufferCount new ${bufferCount == 1 ? 'message' : 'messages'}'
|
? '$bufferCount new ${bufferCount == 1 ? 'message' : 'messages'}'
|
||||||
: 'Resume scroll',
|
: 'Resume scroll',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontFeatures: [
|
fontFeatures: [FontFeature.tabularFigures()],
|
||||||
FontFeature.tabularFigures(),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -259,76 +298,78 @@ class Chat extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEmoteMenu(BuildContext context) {
|
Widget _buildEmoteMenu(BuildContext context) {
|
||||||
|
final menuHeight = context.screenHeight / (context.isPortrait ? 3 : 2);
|
||||||
return AnimatedContainer(
|
return AnimatedContainer(
|
||||||
curve: Curves.ease,
|
curve: Curves.ease,
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
height: chatStore.assetsStore.showEmoteMenu
|
height: chatStore.assetsStore.showEmoteMenu ? menuHeight : 0,
|
||||||
? context.screenHeight / (context.isPortrait ? 3 : 2)
|
|
||||||
: 0,
|
|
||||||
child: AnimatedSwitcher(
|
child: AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 100),
|
duration: const Duration(milliseconds: 100),
|
||||||
switchInCurve: Curves.easeOut,
|
switchInCurve: Curves.easeOut,
|
||||||
switchOutCurve: Curves.easeIn,
|
switchOutCurve: Curves.easeIn,
|
||||||
child: chatStore.assetsStore.showEmoteMenu
|
child: chatStore.assetsStore.showEmoteMenu
|
||||||
? ClipRect(
|
? ClipRect(
|
||||||
child: Column(
|
// Lay the content out at its full target height regardless of
|
||||||
children: [
|
// the animated container's current height, then clip — the
|
||||||
const Divider(),
|
// Column would otherwise overflow (RenderFlex error) as the
|
||||||
Expanded(
|
// height tweens toward zero and can no longer fit the Divider.
|
||||||
child: FrostyPageView(
|
child: OverflowBox(
|
||||||
headers: [
|
minHeight: 0,
|
||||||
'Recent',
|
maxHeight: menuHeight,
|
||||||
if (chatStore.settings.showTwitchEmotes)
|
alignment: Alignment.topCenter,
|
||||||
'Twitch',
|
child: Column(
|
||||||
if (chatStore.settings.show7TVEmotes) '7TV',
|
children: [
|
||||||
if (chatStore.settings.showBTTVEmotes) 'BTTV',
|
const Divider(),
|
||||||
if (chatStore.settings.showFFZEmotes) 'FFZ',
|
Expanded(
|
||||||
],
|
child: FrostyPageView(
|
||||||
tabActions: {
|
headers: [
|
||||||
if (chatStore
|
'Recent',
|
||||||
.assetsStore
|
if (chatStore.settings.showTwitchEmotes) 'Twitch',
|
||||||
.recentEmotes
|
if (chatStore.settings.show7TVEmotes) '7TV',
|
||||||
.isNotEmpty)
|
if (chatStore.settings.showBTTVEmotes) 'BTTV',
|
||||||
0: IconButton(
|
if (chatStore.settings.showFFZEmotes) 'FFZ',
|
||||||
onPressed: () {
|
],
|
||||||
chatStore.assetsStore.recentEmotes.clear();
|
tabActions: {
|
||||||
chatStore.updateNotification(
|
if (chatStore.assetsStore.recentEmotes.isNotEmpty)
|
||||||
'Recent emotes cleared',
|
0: IconButton(
|
||||||
);
|
onPressed: () {
|
||||||
},
|
chatStore.assetsStore.recentEmotes.clear();
|
||||||
icon: const Icon(
|
chatStore.updateNotification(
|
||||||
Icons.delete_outline_rounded,
|
'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,
|
||||||
),
|
),
|
||||||
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,
|
: null,
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,7 @@ class ChatModes extends StatelessWidget {
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
context,
|
context,
|
||||||
).colorScheme.onSurfaceVariant,
|
).colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
31
lib/screens/channel/chat/stores/banned_user_tracker.dart
Normal file
31
lib/screens/channel/chat/stores/banned_user_tracker.dart
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
/// Tracks which users are currently banned or timed out, learned from IRC
|
||||||
|
/// CLEARCHAT events, so the chat can offer an "unban / remove timeout" action
|
||||||
|
/// only when it's actually applicable.
|
||||||
|
///
|
||||||
|
/// Keyed by Twitch user id. A value of null means a permanent ban; a non-null
|
||||||
|
/// [DateTime] is when a timeout expires. Pure logic (clock passed in) so it can
|
||||||
|
/// be unit-tested without the chat store.
|
||||||
|
class BannedUserTracker {
|
||||||
|
final _expiries = <String, DateTime?>{};
|
||||||
|
|
||||||
|
/// Record a permanent ban (CLEARCHAT without a ban-duration).
|
||||||
|
void recordBan(String userId) => _expiries[userId] = null;
|
||||||
|
|
||||||
|
/// Record a timeout (CLEARCHAT with a ban-duration), expiring at [now] + [duration].
|
||||||
|
void recordTimeout(String userId, Duration duration, DateTime now) =>
|
||||||
|
_expiries[userId] = now.add(duration);
|
||||||
|
|
||||||
|
/// Forget a user (e.g. after a successful unban).
|
||||||
|
void clear(String userId) => _expiries.remove(userId);
|
||||||
|
|
||||||
|
/// Whether [userId] is currently banned or within an unexpired timeout.
|
||||||
|
/// Expired timeouts are pruned on read.
|
||||||
|
bool isBannedOrTimedOut(String userId, DateTime now) {
|
||||||
|
if (!_expiries.containsKey(userId)) return false;
|
||||||
|
final expiry = _expiries[userId];
|
||||||
|
if (expiry == null) return true; // permanent ban
|
||||||
|
if (now.isBefore(expiry)) return true; // timeout still active
|
||||||
|
_expiries.remove(userId); // expired — clean up
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -130,7 +130,7 @@ abstract class ChatAssetsStoreBase with Store {
|
||||||
|
|
||||||
@action
|
@action
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
// Retrieve the instance that will allow us to retrieve local search history.
|
// Load SharedPreferences for recent emotes.
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
_recentEmotes =
|
_recentEmotes =
|
||||||
|
|
@ -290,6 +290,18 @@ abstract class ChatAssetsStoreBase with Store {
|
||||||
await Future.wait(futures);
|
await Future.wait(futures);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether any shared chat participant assets have been merged in.
|
||||||
|
bool get hasLoadedSharedChatAssets => _loadedSharedChannelIds.isNotEmpty;
|
||||||
|
|
||||||
|
/// Clears tracking of merged shared chat participant assets so that leaving
|
||||||
|
/// and later re-entering a shared chat session re-fetches cleanly. The caller
|
||||||
|
/// must re-fetch this channel's own assets afterwards (which replaces the
|
||||||
|
/// merged emote/badge maps). Must run within a MobX action.
|
||||||
|
void resetSharedChatAssets() {
|
||||||
|
_loadedSharedChannelIds.clear();
|
||||||
|
sharedSevenTvSetIds.clear();
|
||||||
|
}
|
||||||
|
|
||||||
/// Load and cache shared chat participants for a broadcaster.
|
/// Load and cache shared chat participants for a broadcaster.
|
||||||
/// Returns the list of new participant broadcaster IDs (deduped).
|
/// Returns the list of new participant broadcaster IDs (deduped).
|
||||||
@action
|
@action
|
||||||
|
|
|
||||||
54
lib/screens/channel/chat/stores/chat_interaction_pause.dart
Normal file
54
lib/screens/channel/chat/stores/chat_interaction_pause.dart
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
|
||||||
|
/// Decides whether the chat message list should hold buffered messages back
|
||||||
|
/// because the user is *holding* a message (a long-press candidate).
|
||||||
|
///
|
||||||
|
/// Flushing buffered messages grows the reverse list and pushes the pressed
|
||||||
|
/// message up and out of the build range, which disposes its gesture recognizer
|
||||||
|
/// mid-press and cancels the long-press. Freezing the list while a finger rests
|
||||||
|
/// on a message keeps it under the finger so the press lands.
|
||||||
|
///
|
||||||
|
/// Crucially, the freeze applies *only to a stationary finger*. The moment a
|
||||||
|
/// pointer travels past [kTouchSlop] it's a scroll, not a hold, so the chat
|
||||||
|
/// resumes flowing — otherwise freezing then batch-releasing mid-scroll makes
|
||||||
|
/// scrolling feel like it gets cancelled. This is the same threshold the
|
||||||
|
/// long-press recognizer uses to tell a hold from a drag.
|
||||||
|
///
|
||||||
|
/// Tracks pointers individually so multi-touch resolves correctly: the chat
|
||||||
|
/// only resumes once every stationary pointer has lifted or started moving.
|
||||||
|
class ChatInteractionPause {
|
||||||
|
/// Down position of each active pointer, by pointer id.
|
||||||
|
final _origins = <int, Offset>{};
|
||||||
|
|
||||||
|
/// Pointers that have moved past the slop this gesture — i.e. are scrolling.
|
||||||
|
/// Once a pointer is here it stays until it lifts, so a drag never flips back
|
||||||
|
/// to a hold mid-gesture.
|
||||||
|
final _scrolling = <int>{};
|
||||||
|
|
||||||
|
/// Whether any pointer is down and still stationary (a long-press candidate).
|
||||||
|
bool get isPaused => _origins.length > _scrolling.length;
|
||||||
|
|
||||||
|
/// Register a pointer touching down at [position].
|
||||||
|
void pointerDown(int pointer, Offset position) {
|
||||||
|
_origins[pointer] = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a pointer's position; flags it as scrolling once it passes the slop.
|
||||||
|
void pointerMove(int pointer, Offset position) {
|
||||||
|
final origin = _origins[pointer];
|
||||||
|
if (origin == null) return;
|
||||||
|
if ((position - origin).distance > kTouchSlop) _scrolling.add(pointer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a pointer lifting off (or being cancelled).
|
||||||
|
void pointerUp(int pointer) {
|
||||||
|
_origins.remove(pointer);
|
||||||
|
_scrolling.remove(pointer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force-clear all active pointers (e.g. on dispose or tab switch).
|
||||||
|
void reset() {
|
||||||
|
_origins.clear();
|
||||||
|
_scrolling.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,11 +10,15 @@ import 'package:frosty/models/emotes.dart';
|
||||||
import 'package:frosty/models/events.dart';
|
import 'package:frosty/models/events.dart';
|
||||||
import 'package:frosty/models/irc.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/stores/banned_user_tracker.dart';
|
||||||
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
|
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
|
||||||
|
import 'package:frosty/screens/channel/chat/stores/chat_interaction_pause.dart';
|
||||||
|
import 'package:frosty/screens/channel/chat/stores/shared_chat_bubble_state.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/utils.dart';
|
import 'package:frosty/utils.dart';
|
||||||
import 'package:mobx/mobx.dart';
|
import 'package:mobx/mobx.dart';
|
||||||
|
import 'package:web_socket_channel/io.dart';
|
||||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
|
||||||
part 'chat_store.g.dart';
|
part 'chat_store.g.dart';
|
||||||
|
|
@ -26,7 +30,7 @@ abstract class ChatStoreBase with Store {
|
||||||
/// The total maximum amount of messages in chat.
|
/// The total maximum amount of messages in chat.
|
||||||
static const _messageLimit = 5000;
|
static const _messageLimit = 5000;
|
||||||
|
|
||||||
/// The maximum ammount of messages to render when autoscroll is enabled.
|
/// The maximum amount of messages to render when autoscroll is enabled.
|
||||||
static const _renderMessageLimit = 100;
|
static const _renderMessageLimit = 100;
|
||||||
|
|
||||||
/// IRC commands that should bypass chat delay.
|
/// IRC commands that should bypass chat delay.
|
||||||
|
|
@ -66,32 +70,41 @@ abstract class ChatStoreBase with Store {
|
||||||
'msg_bad_characters',
|
'msg_bad_characters',
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Base height of the bottom bar (input field area).
|
/// Fallback height used to pad the message list for one frame, until the
|
||||||
|
/// real bottom bar reports its measured height via [setBottomBarHeight].
|
||||||
static const _baseBottomBarHeight = 68.0;
|
static const _baseBottomBarHeight = 68.0;
|
||||||
|
|
||||||
/// Height of the autocomplete bar (SizedBox + Divider).
|
/// Server-initiated graceful reconnect notice. Twitch sends this untagged
|
||||||
static const _autocompleteHeight = 51.0;
|
/// before maintenance so clients can re-establish ahead of the close.
|
||||||
|
static const _serverReconnectNotice = ':tmi.twitch.tv RECONNECT';
|
||||||
|
|
||||||
/// Height of the reply bar (Container + Divider).
|
/// Muted words pre-normalized (trimmed, lowercased, empties dropped) by a
|
||||||
static const _replyBarHeight = 41.0;
|
/// reaction on the setting — avoids re-normalizing on every message.
|
||||||
|
var _normalizedMutedWords = <String>[];
|
||||||
|
|
||||||
/// Checks if IRC data contains a command that should bypass the chat delay.
|
/// Whether a single IRC [line] should bypass the chat delay and be handled
|
||||||
/// Returns true if any message in the data contains a bypass command.
|
/// immediately. Displayable chat content (PRIVMSG/USERNOTICE and the
|
||||||
bool _shouldBypassDelay(String data) {
|
/// moderation CLEARCHAT/CLEARMSG) is delayed, so it stays in sync with the
|
||||||
for (final message in data.trimRight().split('\r\n')) {
|
/// delayed video. Everything else is handled at once: control/state messages
|
||||||
if (message.startsWith('@')) {
|
/// (USERSTATE/ROOMSTATE/GLOBALUSERSTATE), NOTICE (so a send rejection surfaces
|
||||||
final parts = message.split(' ');
|
/// immediately instead of one chat-delay later), the reconnect notice, and
|
||||||
if (parts.length >= 3 && _delayBypassCommands.contains(parts[2])) {
|
/// any untagged server line (PING keepalives, CAP, JOIN, the welcome banner)
|
||||||
return true;
|
/// — delaying those would stall the send-ack, drop the PONG, or never start
|
||||||
}
|
/// the buffer drain.
|
||||||
}
|
bool _lineShouldBypassDelay(String line) {
|
||||||
}
|
if (line.isEmpty || line == _serverReconnectNotice) return true;
|
||||||
return false;
|
// Displayable chat content is always IRCv3-tagged; an untagged line is
|
||||||
|
// never delayed.
|
||||||
|
if (!line.startsWith('@')) return true;
|
||||||
|
final parts = line.split(' ');
|
||||||
|
return parts.length >= 3 && _delayBypassCommands.contains(parts[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
int get _chatDelaySeconds => settings.effectiveChatDelay.toInt();
|
Duration get _chatDelay =>
|
||||||
|
Duration(milliseconds: (settings.effectiveChatDelay * 1000).round());
|
||||||
|
|
||||||
bool get _hasActiveChatDelay => settings.showVideo && _chatDelaySeconds > 0;
|
bool get _hasActiveChatDelay =>
|
||||||
|
settings.showVideo && _chatDelay > Duration.zero;
|
||||||
|
|
||||||
final TwitchApi twitchApi;
|
final TwitchApi twitchApi;
|
||||||
|
|
||||||
|
|
@ -131,6 +144,12 @@ abstract class ChatStoreBase with Store {
|
||||||
|
|
||||||
static const _maxRetries = 5;
|
static const _maxRetries = 5;
|
||||||
|
|
||||||
|
/// Ping interval for both the IRC and 7TV WebSocket sockets. Catches
|
||||||
|
/// zombie connections (silent network changes, OS suspends) without
|
||||||
|
/// waking the radio every 15s. Twitch's own recommended keepalive tops
|
||||||
|
/// out at 600s; community libs (e.g. dank-twitch-irc) sit around 30s.
|
||||||
|
static const _pingInterval = Duration(seconds: 60);
|
||||||
|
|
||||||
// The retry counter for exponential backoff.
|
// The retry counter for exponential backoff.
|
||||||
var _retries = 0;
|
var _retries = 0;
|
||||||
|
|
||||||
|
|
@ -158,6 +177,12 @@ abstract class ChatStoreBase with Store {
|
||||||
/// Requested message to be sent by the user. Will only be sent on receipt of a USERNOTICE command.
|
/// Requested message to be sent by the user. Will only be sent on receipt of a USERNOTICE command.
|
||||||
IRCMessage? toSend;
|
IRCMessage? toSend;
|
||||||
|
|
||||||
|
/// Raw text of the most recent outgoing message, retained until the send is
|
||||||
|
/// confirmed. Lets the USERSTATE handler build the local echo even when the
|
||||||
|
/// message was sent before this session's first USERSTATE arrived (so
|
||||||
|
/// [toSend] couldn't be built up front).
|
||||||
|
String? _pendingSentMessageText;
|
||||||
|
|
||||||
/// The list of reaction disposer functions that will be used later when disposing.
|
/// The list of reaction disposer functions that will be used later when disposing.
|
||||||
final reactions = <ReactionDisposer>[];
|
final reactions = <ReactionDisposer>[];
|
||||||
|
|
||||||
|
|
@ -177,11 +202,40 @@ abstract class ChatStoreBase with Store {
|
||||||
/// The set of message IDs that have been revealed by the user (for deleted messages).
|
/// The set of message IDs that have been revealed by the user (for deleted messages).
|
||||||
final revealedMessageIds = ObservableSet<String>();
|
final revealedMessageIds = ObservableSet<String>();
|
||||||
|
|
||||||
|
/// User-driven spotlight/hide selections for shared chat participant
|
||||||
|
/// channels. See [SharedChatBubbleState].
|
||||||
|
final sharedChatBubbles = SharedChatBubbleState();
|
||||||
|
|
||||||
@action
|
@action
|
||||||
void revealMessage(String id) {
|
void revealMessage(String id) {
|
||||||
revealedMessageIds.add(id);
|
revealedMessageIds.add(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a message from [sourceChannelId] should render faded.
|
||||||
|
///
|
||||||
|
/// [overrideCurrentChannelId] is set only when rendering the manual
|
||||||
|
/// "merged mode" view (multiple manually-added tabs interleaved), which
|
||||||
|
/// always fades non-active-tab messages, gated by [focusCurrentChannel].
|
||||||
|
/// Otherwise this is the normal single-connection view, where native
|
||||||
|
/// Twitch shared chat defaults to unfaded and only fades once the user
|
||||||
|
/// spotlights a participant channel via a chat bubble — an explicit
|
||||||
|
/// action that isn't gated by that setting.
|
||||||
|
bool shouldFadeMessage({
|
||||||
|
required String? sourceChannelId,
|
||||||
|
required String? overrideCurrentChannelId,
|
||||||
|
}) {
|
||||||
|
if (sourceChannelId == null) return false;
|
||||||
|
|
||||||
|
final currentChannelId = overrideCurrentChannelId ?? channelId;
|
||||||
|
if (sourceChannelId == currentChannelId) return false;
|
||||||
|
|
||||||
|
final isMerged = overrideCurrentChannelId != null;
|
||||||
|
if (isMerged) return settings.focusCurrentChannel;
|
||||||
|
|
||||||
|
return sharedChatBubbles.focusedChannelIds.isNotEmpty &&
|
||||||
|
!sharedChatBubbles.isFocused(sourceChannelId);
|
||||||
|
}
|
||||||
|
|
||||||
/// Timer used for dismissing the notification.
|
/// Timer used for dismissing the notification.
|
||||||
Timer? _notificationTimer;
|
Timer? _notificationTimer;
|
||||||
|
|
||||||
|
|
@ -217,21 +271,51 @@ abstract class ChatStoreBase with Store {
|
||||||
@computed
|
@computed
|
||||||
List<IRCMessage> get renderMessages {
|
List<IRCMessage> get renderMessages {
|
||||||
// If autoscroll is disabled, render ALL messages in chat.
|
// If autoscroll is disabled, render ALL messages in chat.
|
||||||
// The second condition is to prevent an out of index error with sublist.
|
// The second condition is to prevent an out-of-range index error with sublist.
|
||||||
if (!_autoScroll || _messages.length < _renderMessageLimit) {
|
final base = (!_autoScroll || _messages.length < _renderMessageLimit)
|
||||||
return _messages;
|
? _messages
|
||||||
}
|
: _messages.sublist(_messages.length - _renderMessageLimit);
|
||||||
|
|
||||||
// When autoscroll is enabled, only show the first [_renderMessageLimit] messages.
|
// Filter out messages from shared chat participant channels the user
|
||||||
// This will improve performance by only rendering a limited amount of messages
|
// has hidden. Short-circuit when nothing is hidden (the common case).
|
||||||
// instead of the entire history at all times.
|
if (sharedChatBubbles.hiddenChannelIds.isEmpty) return base;
|
||||||
return _messages.sublist(_messages.length - _renderMessageLimit);
|
return base.where((message) {
|
||||||
|
final sourceChannelId = message.sourceChannelId;
|
||||||
|
return sourceChannelId == null ||
|
||||||
|
!sharedChatBubbles.hiddenChannelIds.contains(sourceChannelId);
|
||||||
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If the chat should automatically scroll/jump to the latest message.
|
/// If the chat should automatically scroll/jump to the latest message.
|
||||||
@readonly
|
@readonly
|
||||||
var _autoScroll = true;
|
var _autoScroll = true;
|
||||||
|
|
||||||
|
/// Tracks users banned/timed-out via CLEARCHAT so the moderation menu only
|
||||||
|
/// offers an unban action when it applies.
|
||||||
|
final _bannedUserTracker = BannedUserTracker();
|
||||||
|
|
||||||
|
/// Whether [userId] is currently banned or within an unexpired timeout
|
||||||
|
/// (as observed from CLEARCHAT in this session).
|
||||||
|
bool isUserBannedOrTimedOut(String userId) =>
|
||||||
|
_bannedUserTracker.isBannedOrTimedOut(userId, DateTime.now());
|
||||||
|
|
||||||
|
/// Forget a user's ban state after a successful unban so the action hides.
|
||||||
|
void clearUserBanState(String userId) => _bannedUserTracker.clear(userId);
|
||||||
|
|
||||||
|
/// Freezes the rendered list while the user is touching it, so a long-press
|
||||||
|
/// target can't scroll out from under the finger and get its gesture
|
||||||
|
/// recognizer disposed mid-press. Plain field (not observable): it only gates
|
||||||
|
/// the periodic [addMessages] flush, which reads it each 200ms tick.
|
||||||
|
final _interactionPause = ChatInteractionPause();
|
||||||
|
|
||||||
|
/// Hooks for the message list to report touch start/move/end (see [Chat]).
|
||||||
|
void onMessageListPointerDown(int pointer, Offset position) =>
|
||||||
|
_interactionPause.pointerDown(pointer, position);
|
||||||
|
void onMessageListPointerMove(int pointer, Offset position) =>
|
||||||
|
_interactionPause.pointerMove(pointer, position);
|
||||||
|
void onMessageListPointerUp(int pointer) =>
|
||||||
|
_interactionPause.pointerUp(pointer);
|
||||||
|
|
||||||
@readonly
|
@readonly
|
||||||
var _inputText = '';
|
var _inputText = '';
|
||||||
|
|
||||||
|
|
@ -300,25 +384,26 @@ abstract class ChatStoreBase with Store {
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Current bottom bar height based on visible overlays.
|
/// Measured height of the rendered bottom bar (input plus any reply or
|
||||||
/// Used to dynamically adjust chat message list padding.
|
/// autocomplete overlays). The bar reports this via [setBottomBarHeight] so
|
||||||
|
/// the message list's bottom padding tracks the bar's *true* height —
|
||||||
|
/// including multi-line input growth and OS text scaling, which fixed
|
||||||
|
/// per-overlay constants silently undercount.
|
||||||
|
final _measuredBottomBarHeight = Observable<double?>(null);
|
||||||
|
|
||||||
|
/// Current bottom bar height, used to dynamically pad the message list so the
|
||||||
|
/// bar never overlaps messages. Falls back to the single-line base height
|
||||||
|
/// until the real bar reports its first measurement.
|
||||||
@computed
|
@computed
|
||||||
double get bottomBarHeight {
|
double get bottomBarHeight =>
|
||||||
var height = _baseBottomBarHeight;
|
_measuredBottomBarHeight.value ?? _baseBottomBarHeight;
|
||||||
|
|
||||||
// Add reply bar height when replying to a message.
|
/// Reports the bar's real rendered height. Sub-pixel changes are ignored to
|
||||||
if (replyingToMessage != null) {
|
/// avoid needless message-list relayouts.
|
||||||
height += _replyBarHeight;
|
void setBottomBarHeight(double height) {
|
||||||
}
|
final current = _measuredBottomBarHeight.value;
|
||||||
|
if (current != null && (current - height).abs() < 0.5) return;
|
||||||
// Add autocomplete bar height when showing matches.
|
runInAction(() => _measuredBottomBarHeight.value = height);
|
||||||
if (settings.autocomplete &&
|
|
||||||
((_showEmoteAutocomplete && matchingEmotes.isNotEmpty) ||
|
|
||||||
(_showMentionAutocomplete && matchingChatters.isNotEmpty))) {
|
|
||||||
height += _autocompleteHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
return height;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ChatStoreBase({
|
ChatStoreBase({
|
||||||
|
|
@ -337,6 +422,17 @@ abstract class ChatStoreBase with Store {
|
||||||
reaction((_) => auth.isLoggedIn, (_) => _channel?.sink.close(1000)),
|
reaction((_) => auth.isLoggedIn, (_) => _channel?.sink.close(1000)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Normalize muted words once per settings change instead of per message
|
||||||
|
// — the filter runs in the IRC hot path (50+ msgs/s in busy channels).
|
||||||
|
reactions.add(
|
||||||
|
reaction((_) => settings.mutedWords.toList(), (List<String> words) {
|
||||||
|
_normalizedMutedWords = words
|
||||||
|
.map((word) => word.trim().toLowerCase())
|
||||||
|
.where((word) => word.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
}, fireImmediately: true),
|
||||||
|
);
|
||||||
|
|
||||||
reactions.add(
|
reactions.add(
|
||||||
reaction(
|
reaction(
|
||||||
(_) => [
|
(_) => [
|
||||||
|
|
@ -405,6 +501,10 @@ abstract class ChatStoreBase with Store {
|
||||||
connectToChat();
|
connectToChat();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh which channels the user moderates on entry so mod actions reflect
|
||||||
|
// status granted/revoked since login. Fire-and-forget, like blocked users.
|
||||||
|
if (auth.isLoggedIn) auth.user.refreshModeratedChannels();
|
||||||
|
|
||||||
// Tell the scrollController to determine when auto-scroll should be enabled or disabled.
|
// Tell the scrollController to determine when auto-scroll should be enabled or disabled.
|
||||||
scrollController.addListener(() {
|
scrollController.addListener(() {
|
||||||
// If the scroll position is at the latest message (maximum possible), enable autoscroll.
|
// If the scroll position is at the latest message (maximum possible), enable autoscroll.
|
||||||
|
|
@ -449,7 +549,7 @@ abstract class ChatStoreBase with Store {
|
||||||
) {
|
) {
|
||||||
queue.add(
|
queue.add(
|
||||||
_DelayedCallback(
|
_DelayedCallback(
|
||||||
releaseAt: DateTime.now().add(Duration(seconds: _chatDelaySeconds)),
|
releaseAt: DateTime.now().add(_chatDelay),
|
||||||
callback: callback,
|
callback: callback,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -518,11 +618,29 @@ abstract class ChatStoreBase with Store {
|
||||||
// To account for this, split by CRLF, then loop and process each message.
|
// To account for this, split by CRLF, then loop and process each message.
|
||||||
for (final message in data.trimRight().split('\r\n')) {
|
for (final message in data.trimRight().split('\r\n')) {
|
||||||
// debugPrint('$message\n');
|
// debugPrint('$message\n');
|
||||||
|
if (message == _serverReconnectNotice) {
|
||||||
|
// Twitch is about to close the socket for maintenance — close it
|
||||||
|
// ourselves so the existing onDone path reconnects with no backoff
|
||||||
|
// (first retry is immediate) instead of waiting for the server to
|
||||||
|
// drop us mid-message.
|
||||||
|
_channel?.sink.close(1000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (message.startsWith('@')) {
|
if (message.startsWith('@')) {
|
||||||
final parsedIRCMessage = IRCMessage.fromString(
|
// Parse defensively: a single malformed message must never abort the
|
||||||
message,
|
// loop, or one bad frame would drop every other message batched with
|
||||||
userLogin: auth.user.details?.login,
|
// it (e.g. an entire recent-messages backlog). Skip and keep going.
|
||||||
);
|
final IRCMessage parsedIRCMessage;
|
||||||
|
try {
|
||||||
|
parsedIRCMessage = IRCMessage.fromString(
|
||||||
|
message,
|
||||||
|
userLogin: auth.user.details?.login,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to parse IRC message: $e');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (parsedIRCMessage.user != null) {
|
if (parsedIRCMessage.user != null) {
|
||||||
chatDetailsStore.chatUsers.add(parsedIRCMessage.user!);
|
chatDetailsStore.chatUsers.add(parsedIRCMessage.user!);
|
||||||
|
|
@ -540,19 +658,17 @@ abstract class ChatStoreBase with Store {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter messages containing any muted words.
|
// Filter messages containing any muted words. Must use `continue`
|
||||||
if (parsedIRCMessage.message != null) {
|
// (not `return`) — a single IRC frame can batch many messages, and
|
||||||
final List<String> mutedWords = settings.mutedWords;
|
// returning would drop the rest of the frame with the matched one.
|
||||||
|
if (parsedIRCMessage.message != null &&
|
||||||
// check if the message contains any of the muted words
|
_normalizedMutedWords.isNotEmpty) {
|
||||||
for (final word in mutedWords) {
|
final msg = parsedIRCMessage.message!.toLowerCase();
|
||||||
if (parsedIRCMessage.message!
|
final words = settings.matchWholeWord ? msg.split(' ') : null;
|
||||||
.toLowerCase()
|
final muted = _normalizedMutedWords.any(
|
||||||
.split(settings.matchWholeWord ? ' ' : '')
|
(word) => words != null ? words.contains(word) : msg.contains(word),
|
||||||
.contains(word.toLowerCase())) {
|
);
|
||||||
return;
|
if (muted) continue;
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (parsedIRCMessage.command) {
|
switch (parsedIRCMessage.command) {
|
||||||
|
|
@ -568,6 +684,9 @@ abstract class ChatStoreBase with Store {
|
||||||
_sendingTimeoutTimer?.cancel();
|
_sendingTimeoutTimer?.cancel();
|
||||||
_isWaitingForAck = false;
|
_isWaitingForAck = false;
|
||||||
toSend = null;
|
toSend = null;
|
||||||
|
// Drop the retained text so a later USERSTATE can't render the
|
||||||
|
// rejected message as a deferred echo.
|
||||||
|
_pendingSentMessageText = null;
|
||||||
|
|
||||||
// Show notification with Twitch's rejection message
|
// Show notification with Twitch's rejection message
|
||||||
if (parsedIRCMessage.message != null) {
|
if (parsedIRCMessage.message != null) {
|
||||||
|
|
@ -577,40 +696,82 @@ abstract class ChatStoreBase with Store {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update shared chat mode based on source-room-id tag presence
|
// Update shared chat mode based on source-room-id tag presence.
|
||||||
final wasShared = _isInSharedChatMode;
|
// Only PRIVMSG reliably carries this tag for the session's
|
||||||
_isInSharedChatMode = parsedIRCMessage.tags.containsKey(
|
// duration — NOTICE/USERNOTICE (subs, raids, announcements)
|
||||||
'source-room-id',
|
// don't always, so gating on those too would flap this flag off
|
||||||
);
|
// and on within a single live session (now visibly, since the
|
||||||
// On transition into shared chat mode, fetch assets for participants.
|
// shared chat bubble bar reads it — previously this only risked
|
||||||
if (!wasShared && _isInSharedChatMode) {
|
// a redundant asset refetch, guarded below).
|
||||||
assetsStore.fetchSharedChatAssets(
|
if (parsedIRCMessage.command == Command.privateMessage) {
|
||||||
channelId: channelId,
|
final wasShared = _isInSharedChatMode;
|
||||||
headers: auth.headersTwitch,
|
_isInSharedChatMode = parsedIRCMessage.tags.containsKey(
|
||||||
onEmoteError: (error) {
|
'source-room-id',
|
||||||
debugPrint(error.toString());
|
|
||||||
return <Emote>[];
|
|
||||||
},
|
|
||||||
onBadgeError: (error) {
|
|
||||||
debugPrint(error.toString());
|
|
||||||
return <ChatBadge>[];
|
|
||||||
},
|
|
||||||
showTwitchEmotes: settings.showTwitchEmotes,
|
|
||||||
showTwitchBadges: settings.showTwitchBadges,
|
|
||||||
show7TVEmotes: settings.show7TVEmotes,
|
|
||||||
showBTTVEmotes: settings.showBTTVEmotes,
|
|
||||||
showFFZEmotes: settings.showFFZEmotes,
|
|
||||||
showFFZBadges: settings.showFFZBadges,
|
|
||||||
);
|
);
|
||||||
|
// On transition into shared chat mode, fetch assets for participants.
|
||||||
|
if (!wasShared && _isInSharedChatMode) {
|
||||||
|
assetsStore.fetchSharedChatAssets(
|
||||||
|
channelId: channelId,
|
||||||
|
headers: auth.headersTwitch,
|
||||||
|
onEmoteError: (error) {
|
||||||
|
debugPrint(error.toString());
|
||||||
|
return <Emote>[];
|
||||||
|
},
|
||||||
|
onBadgeError: (error) {
|
||||||
|
debugPrint(error.toString());
|
||||||
|
return <String, ChatBadge>{};
|
||||||
|
},
|
||||||
|
showTwitchEmotes: settings.showTwitchEmotes,
|
||||||
|
showTwitchBadges: settings.showTwitchBadges,
|
||||||
|
show7TVEmotes: settings.show7TVEmotes,
|
||||||
|
showBTTVEmotes: settings.showBTTVEmotes,
|
||||||
|
showFFZEmotes: settings.showFFZEmotes,
|
||||||
|
showFFZBadges: settings.showFFZBadges,
|
||||||
|
);
|
||||||
|
} else if (wasShared &&
|
||||||
|
!_isInSharedChatMode &&
|
||||||
|
assetsStore.hasLoadedSharedChatAssets) {
|
||||||
|
// On leaving shared chat, drop the participants' merged emotes and
|
||||||
|
// badges and restore just this channel's assets — otherwise the
|
||||||
|
// other streamer's emote set and badges linger (#522). Guarded on
|
||||||
|
// having actually loaded shared assets so a flapping tag can't
|
||||||
|
// trigger redundant refetches.
|
||||||
|
assetsStore.resetSharedChatAssets();
|
||||||
|
getAssets();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
messageBuffer.add(parsedIRCMessage);
|
messageBuffer.add(parsedIRCMessage);
|
||||||
break;
|
break;
|
||||||
case Command.clearChat:
|
case Command.clearChat:
|
||||||
|
// Track the banned/timed-out user so the chat can offer an unban
|
||||||
|
// action only when applicable. target-user-id is absent for a
|
||||||
|
// whole-chat clear.
|
||||||
|
final clearedUserId = parsedIRCMessage.tags['target-user-id'];
|
||||||
|
if (clearedUserId != null) {
|
||||||
|
final banDuration = parsedIRCMessage.tags['ban-duration'];
|
||||||
|
final seconds = banDuration != null
|
||||||
|
? int.tryParse(banDuration)
|
||||||
|
: null;
|
||||||
|
if (seconds != null) {
|
||||||
|
_bannedUserTracker.recordTimeout(
|
||||||
|
clearedUserId,
|
||||||
|
Duration(seconds: seconds),
|
||||||
|
DateTime.now(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_bannedUserTracker.recordBan(clearedUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
IRCMessage.clearChat(
|
IRCMessage.clearChat(
|
||||||
messages: _messages,
|
messages: _messages,
|
||||||
bufferedMessages: messageBuffer,
|
bufferedMessages: messageBuffer,
|
||||||
ircMessage: parsedIRCMessage,
|
ircMessage: parsedIRCMessage,
|
||||||
);
|
);
|
||||||
|
// clearChat marks messages in place, which doesn't notify the
|
||||||
|
// observable list; reassign so renderMessages recomputes and the
|
||||||
|
// ban/timeout strike-through shows without waiting for the next
|
||||||
|
// message.
|
||||||
|
_messages = _messages.toList().asObservable();
|
||||||
break;
|
break;
|
||||||
case Command.clearMessage:
|
case Command.clearMessage:
|
||||||
IRCMessage.clearMessage(
|
IRCMessage.clearMessage(
|
||||||
|
|
@ -618,6 +779,9 @@ abstract class ChatStoreBase with Store {
|
||||||
bufferedMessages: messageBuffer,
|
bufferedMessages: messageBuffer,
|
||||||
ircMessage: parsedIRCMessage,
|
ircMessage: parsedIRCMessage,
|
||||||
);
|
);
|
||||||
|
// Same as clearChat: force a re-render so the deleted message
|
||||||
|
// updates immediately rather than on the next incoming message.
|
||||||
|
_messages = _messages.toList().asObservable();
|
||||||
break;
|
break;
|
||||||
case Command.roomState:
|
case Command.roomState:
|
||||||
chatDetailsStore.roomState = chatDetailsStore.roomState
|
chatDetailsStore.roomState = chatDetailsStore.roomState
|
||||||
|
|
@ -626,23 +790,13 @@ abstract class ChatStoreBase with Store {
|
||||||
case Command.userState:
|
case Command.userState:
|
||||||
_userState = _userState.fromIRCMessage(parsedIRCMessage);
|
_userState = _userState.fromIRCMessage(parsedIRCMessage);
|
||||||
|
|
||||||
// USERSTATE arrival confirms message was sent, even without id tag
|
// A USERSTATE following our PRIVMSG confirms the send. Finalize
|
||||||
if (toSend != null) {
|
// regardless of whether a local echo was pre-built — gating this on
|
||||||
final messageId = parsedIRCMessage.tags['id'];
|
// `toSend` left the player stuck "sending" (and firing a false
|
||||||
if (messageId != null) {
|
// "may not have been sent") whenever the message went out before
|
||||||
toSend!.tags['id'] = messageId;
|
// this session's first USERSTATE populated `_userState`.
|
||||||
} else {
|
if (_isWaitingForAck) {
|
||||||
// Fallback ID for edge cases where Twitch doesn't provide one
|
_finalizeSend(parsedIRCMessage.tags['id']);
|
||||||
toSend!.tags['id'] =
|
|
||||||
'local-${DateTime.now().millisecondsSinceEpoch}';
|
|
||||||
}
|
|
||||||
messageBuffer.add(toSend!);
|
|
||||||
toSend = null;
|
|
||||||
// Reset sending state and clear text/reply after successful confirmation
|
|
||||||
_sendingTimeoutTimer?.cancel();
|
|
||||||
_isWaitingForAck = false;
|
|
||||||
textController.clear();
|
|
||||||
replyingToMessage = null;
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case Command.globalUserState:
|
case Command.globalUserState:
|
||||||
|
|
@ -740,9 +894,12 @@ abstract class ChatStoreBase with Store {
|
||||||
return <Emote>[];
|
return <Emote>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
List<ChatBadge> onBadgeError(dynamic error) {
|
// Returns a Map, not a List: this is attached via `.catchError` to the
|
||||||
|
// `getBadgesChannel` future (a Future<Map<String, ChatBadge>>), so the
|
||||||
|
// handler must return that type or Dart throws at runtime.
|
||||||
|
Map<String, ChatBadge> onBadgeError(dynamic error) {
|
||||||
debugPrint(error.toString());
|
debugPrint(error.toString());
|
||||||
return <ChatBadge>[];
|
return <String, ChatBadge>{};
|
||||||
}
|
}
|
||||||
|
|
||||||
final baseAssets = assetsStore.assetsFuture(
|
final baseAssets = assetsStore.assetsFuture(
|
||||||
|
|
@ -806,8 +963,9 @@ abstract class ChatStoreBase with Store {
|
||||||
|
|
||||||
_sevenTVChannel?.sink.close(1000);
|
_sevenTVChannel?.sink.close(1000);
|
||||||
_clearPendingDelayedCallbacks(clearChat: false);
|
_clearPendingDelayedCallbacks(clearChat: false);
|
||||||
_sevenTVChannel = WebSocketChannel.connect(
|
_sevenTVChannel = IOWebSocketChannel.connect(
|
||||||
Uri.parse('wss://events.7tv.io/v3'),
|
Uri.parse('wss://events.7tv.io/v3'),
|
||||||
|
pingInterval: _pingInterval,
|
||||||
);
|
);
|
||||||
|
|
||||||
void listener(dynamic data) {
|
void listener(dynamic data) {
|
||||||
|
|
@ -894,8 +1052,9 @@ abstract class ChatStoreBase with Store {
|
||||||
_clearPendingDelayedCallbacks(clearSevenTV: false);
|
_clearPendingDelayedCallbacks(clearSevenTV: false);
|
||||||
|
|
||||||
_channel?.sink.close(1000);
|
_channel?.sink.close(1000);
|
||||||
_channel = WebSocketChannel.connect(
|
_channel = IOWebSocketChannel.connect(
|
||||||
Uri.parse('wss://irc-ws.chat.twitch.tv:443'),
|
Uri.parse('wss://irc-ws.chat.twitch.tv:443'),
|
||||||
|
pingInterval: _pingInterval,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only show chat delay countdown on initial connection or video toggle, not on reconnects
|
// Only show chat delay countdown on initial connection or video toggle, not on reconnects
|
||||||
|
|
@ -908,13 +1067,27 @@ abstract class ChatStoreBase with Store {
|
||||||
(data) {
|
(data) {
|
||||||
final dataStr = data.toString();
|
final dataStr = data.toString();
|
||||||
|
|
||||||
// Process immediately if delay is disabled or command should bypass delay
|
// No active delay: process the whole frame immediately.
|
||||||
if (!_hasActiveChatDelay || _shouldBypassDelay(dataStr)) {
|
if (!_hasActiveChatDelay) {
|
||||||
_handleIRCData(dataStr);
|
_handleIRCData(dataStr);
|
||||||
} else {
|
return;
|
||||||
_enqueueDelayedCallback(_pendingChatCallbacks, () {
|
}
|
||||||
_handleIRCData(dataStr);
|
|
||||||
});
|
// Dispatch each line individually so a bypass-eligible control message
|
||||||
|
// isn't dragged behind the delay by chat lines batched in the same
|
||||||
|
// frame — and, conversely, so a delayed chat line can't hold up a
|
||||||
|
// PING/USERSTATE that shares the frame.
|
||||||
|
for (final line in dataStr.split('\r\n')) {
|
||||||
|
final trimmed = line.trimRight();
|
||||||
|
if (trimmed.isEmpty) continue;
|
||||||
|
if (_lineShouldBypassDelay(trimmed)) {
|
||||||
|
_handleIRCData(trimmed);
|
||||||
|
} else {
|
||||||
|
_enqueueDelayedCallback(
|
||||||
|
_pendingChatCallbacks,
|
||||||
|
() => _handleIRCData(trimmed),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) {
|
onError: (error) {
|
||||||
|
|
@ -935,6 +1108,14 @@ abstract class ChatStoreBase with Store {
|
||||||
_cancelChatDelayCountdown();
|
_cancelChatDelayCountdown();
|
||||||
_chatDelaySyncCompleted = false;
|
_chatDelaySyncCompleted = false;
|
||||||
|
|
||||||
|
// Reset send state — any in-flight message is unrecoverable across
|
||||||
|
// a disconnect, so don't leave the user blocked waiting for an ack
|
||||||
|
// that will never arrive.
|
||||||
|
_sendingTimeoutTimer?.cancel();
|
||||||
|
_isWaitingForAck = false;
|
||||||
|
toSend = null;
|
||||||
|
_pendingSentMessageText = null;
|
||||||
|
|
||||||
if (_shouldDisconnect) {
|
if (_shouldDisconnect) {
|
||||||
_sevenTVChannel?.sink.close(1000);
|
_sevenTVChannel?.sink.close(1000);
|
||||||
return;
|
return;
|
||||||
|
|
@ -1074,7 +1255,11 @@ abstract class ChatStoreBase with Store {
|
||||||
void addMessages() {
|
void addMessages() {
|
||||||
_flushDueDelayedCallbacks();
|
_flushDueDelayedCallbacks();
|
||||||
|
|
||||||
if (!_autoScroll || messageBuffer.isEmpty) return;
|
// Hold messages back while the user is touching the list so the pressed
|
||||||
|
// message stays put (and alive) for the duration of a long-press.
|
||||||
|
if (!_autoScroll || _interactionPause.isPaused || messageBuffer.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_messages.addAll(messageBuffer);
|
_messages.addAll(messageBuffer);
|
||||||
messageBuffer.clear();
|
messageBuffer.clear();
|
||||||
|
|
@ -1105,7 +1290,7 @@ abstract class ChatStoreBase with Store {
|
||||||
messageBuffer.clear();
|
messageBuffer.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
var remainingSeconds = _chatDelaySeconds;
|
var remainingSeconds = _chatDelay.inSeconds;
|
||||||
|
|
||||||
// Create and store reference to the countdown message
|
// Create and store reference to the countdown message
|
||||||
_countdownMessage = IRCMessage.createNotice(
|
_countdownMessage = IRCMessage.createNotice(
|
||||||
|
|
@ -1190,53 +1375,93 @@ abstract class ChatStoreBase with Store {
|
||||||
// Text field and reply state cleared on USERSTATE confirmation (not optimistically)
|
// Text field and reply state cleared on USERSTATE confirmation (not optimistically)
|
||||||
// to preserve message text if Twitch rejects it (e.g., slow mode)
|
// to preserve message text if Twitch rejects it (e.g., slow mode)
|
||||||
|
|
||||||
// Start timeout timer in case server doesn't respond (network failure)
|
// Fallback in case the confirming USERSTATE never arrives. Twitch can omit
|
||||||
// Must be outside userStateString conditional to always have a safety net
|
// or drop the USERSTATE that acknowledges a send — intermittently, and more
|
||||||
|
// often under a long chat delay — even though the message was delivered.
|
||||||
|
// A missing ack is therefore NOT evidence of failure, so rather than cry
|
||||||
|
// wolf with a "may not have been sent" error (which pushes the user to
|
||||||
|
// resend and double-post), optimistically finalize the send: render the
|
||||||
|
// local echo and clear the pending state. Genuine rejections almost always
|
||||||
|
// arrive first — the rejection NOTICE bypasses the chat delay, so it lands
|
||||||
|
// well within this window and clears the pending send before the timeout. A
|
||||||
|
// rejection arriving after the timeout has finalized is treated as sent (the
|
||||||
|
// echo stays), an accepted edge given how early NOTICEs arrive. Scaled by
|
||||||
|
// the chat delay so a legitimately delayed USERSTATE is given time to land.
|
||||||
_sendingTimeoutTimer?.cancel();
|
_sendingTimeoutTimer?.cancel();
|
||||||
_sendingTimeoutTimer = Timer(const Duration(seconds: 10), () {
|
_sendingTimeoutTimer = Timer(const Duration(seconds: 10) + _chatDelay, () {
|
||||||
// Guard against execution after disposal
|
if (_shouldDisconnect || !_isWaitingForAck) return;
|
||||||
if (_shouldDisconnect) return;
|
runInAction(() => _finalizeSend(null));
|
||||||
|
|
||||||
if (_isWaitingForAck) {
|
|
||||||
_isWaitingForAck = false;
|
|
||||||
toSend = null;
|
|
||||||
HapticFeedback.heavyImpact();
|
|
||||||
updateNotification('Message may not have been sent. Please try again.');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Obtain the logged-in user's appearance in chat with USERSTATE and create the full message to render.
|
// Build the optimistic local echo from the current USERSTATE. If the
|
||||||
var userStateString = _userState.raw;
|
// message is sent before this session's first USERSTATE arrives,
|
||||||
if (userStateString != null) {
|
// _buildLocalEcho returns null and the echo is built later, when the
|
||||||
if (message.length > 3 && message.substring(0, 3) == '/me') {
|
// confirming USERSTATE lands (see the Command.userState handler). The raw
|
||||||
userStateString +=
|
// text is retained so that deferred build is possible.
|
||||||
' :\x01ACTION ${message.replaceRange(0, 3, '').trim()}\x01';
|
_pendingSentMessageText = message;
|
||||||
} else {
|
toSend = _buildLocalEcho(message);
|
||||||
userStateString +=
|
}
|
||||||
' :${replyingToMessage?.tags['display-name'] != null ? '@${replyingToMessage!.tags['display-name']} ' : ''}${message.trim()}';
|
|
||||||
}
|
|
||||||
|
|
||||||
final userChatMessage = IRCMessage.fromString(userStateString);
|
/// Renders the pending outgoing message's local echo (building it lazily if
|
||||||
userChatMessage.localEmotes?.addAll(assetsStore.userEmoteToObject);
|
/// it couldn't be built at send time) and clears all send-pending state.
|
||||||
if (auth.isLoggedIn && auth.user.details != null) {
|
/// Shared by the USERSTATE confirmation and the optimistic timeout fallback.
|
||||||
userChatMessage.tags['user-id'] = auth.user.details!.id;
|
/// [serverMessageId] is the `id` tag from the confirming USERSTATE, or null
|
||||||
}
|
/// when finalizing optimistically without one.
|
||||||
|
void _finalizeSend(String? serverMessageId) {
|
||||||
if (replyingToMessage != null && replyingToMessage!.tags['id'] != null) {
|
toSend ??= _pendingSentMessageText != null
|
||||||
userChatMessage.tags['reply-parent-msg-id'] =
|
? _buildLocalEcho(_pendingSentMessageText!)
|
||||||
replyingToMessage!.tags['id']!;
|
: null;
|
||||||
userChatMessage.tags['reply-parent-display-name'] =
|
if (toSend != null) {
|
||||||
replyingToMessage!.tags['display-name'] ?? '';
|
// Prefer the server id; fall back to any id already set, then a local one.
|
||||||
userChatMessage.tags['reply-parent-msg-body'] =
|
toSend!.tags['id'] =
|
||||||
replyingToMessage!.message ?? '';
|
serverMessageId ??
|
||||||
}
|
toSend!.tags['id'] ??
|
||||||
|
'local-${DateTime.now().millisecondsSinceEpoch}';
|
||||||
// Ensure a timestamp so merged-mode sorting works correctly.
|
messageBuffer.add(toSend!);
|
||||||
userChatMessage.tags['tmi-sent-ts'] ??=
|
toSend = null;
|
||||||
'${DateTime.now().millisecondsSinceEpoch}';
|
|
||||||
|
|
||||||
toSend = userChatMessage;
|
|
||||||
}
|
}
|
||||||
|
_pendingSentMessageText = null;
|
||||||
|
_sendingTimeoutTimer?.cancel();
|
||||||
|
_isWaitingForAck = false;
|
||||||
|
textController.clear();
|
||||||
|
replyingToMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the optimistic local echo of an outgoing [message] from the current
|
||||||
|
/// USERSTATE, or null if no USERSTATE has been received yet this session
|
||||||
|
/// (the echo can't be rendered without the user's badges/color/display-name).
|
||||||
|
IRCMessage? _buildLocalEcho(String message) {
|
||||||
|
var userStateString = _userState.raw;
|
||||||
|
if (userStateString == null) return null;
|
||||||
|
|
||||||
|
if (message == '/me' || message.startsWith('/me ')) {
|
||||||
|
userStateString +=
|
||||||
|
' :\x01ACTION ${message.replaceRange(0, 3, '').trim()}\x01';
|
||||||
|
} else {
|
||||||
|
userStateString +=
|
||||||
|
' :${replyingToMessage?.tags['display-name'] != null ? '@${replyingToMessage!.tags['display-name']} ' : ''}${message.trim()}';
|
||||||
|
}
|
||||||
|
|
||||||
|
final userChatMessage = IRCMessage.fromString(userStateString);
|
||||||
|
userChatMessage.localEmotes?.addAll(assetsStore.userEmoteToObject);
|
||||||
|
if (auth.isLoggedIn && auth.user.details != null) {
|
||||||
|
userChatMessage.tags['user-id'] = auth.user.details!.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replyingToMessage != null && replyingToMessage!.tags['id'] != null) {
|
||||||
|
userChatMessage.tags['reply-parent-msg-id'] =
|
||||||
|
replyingToMessage!.tags['id']!;
|
||||||
|
userChatMessage.tags['reply-parent-display-name'] =
|
||||||
|
replyingToMessage!.tags['display-name'] ?? '';
|
||||||
|
userChatMessage.tags['reply-parent-msg-body'] =
|
||||||
|
replyingToMessage!.message ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure a timestamp so merged-mode sorting works correctly.
|
||||||
|
userChatMessage.tags['tmi-sent-ts'] ??=
|
||||||
|
'${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
|
||||||
|
return userChatMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adds the given [emote] to the chat textfield.
|
/// Adds the given [emote] to the chat textfield.
|
||||||
|
|
@ -1343,6 +1568,8 @@ abstract class ChatStoreBase with Store {
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_shouldDisconnect = true;
|
_shouldDisconnect = true;
|
||||||
|
|
||||||
|
_interactionPause.reset();
|
||||||
|
|
||||||
_clearPendingDelayedCallbacks();
|
_clearPendingDelayedCallbacks();
|
||||||
|
|
||||||
_messageBufferTimer?.cancel();
|
_messageBufferTimer?.cancel();
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import 'package:frosty/models/irc.dart';
|
||||||
import 'package:frosty/models/user.dart';
|
import 'package:frosty/models/user.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/stores/chat_assets_store.dart';
|
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
|
||||||
|
import 'package:frosty/screens/channel/chat/stores/chat_interaction_pause.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/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';
|
||||||
|
|
@ -55,6 +56,10 @@ class ChatTabInfo {
|
||||||
/// The ChatStore for this tab. Null until the tab is activated (lazy loading).
|
/// The ChatStore for this tab. Null until the tab is activated (lazy loading).
|
||||||
ChatStore? chatStore;
|
ChatStore? chatStore;
|
||||||
|
|
||||||
|
/// The id of the most recent message visible the last time the user viewed
|
||||||
|
/// this tab. Used to compute unread state — null means "no baseline yet".
|
||||||
|
String? lastSeenMessageId;
|
||||||
|
|
||||||
/// Whether this is the primary tab (first tab, cannot be removed).
|
/// Whether this is the primary tab (first tab, cannot be removed).
|
||||||
final bool isPrimary;
|
final bool isPrimary;
|
||||||
|
|
||||||
|
|
@ -147,6 +152,19 @@ abstract class ChatTabsStoreBase with Store {
|
||||||
/// Timer that drives merged message rendering at a fixed cadence.
|
/// Timer that drives merged message rendering at a fixed cadence.
|
||||||
Timer? _mergedRenderTimer;
|
Timer? _mergedRenderTimer;
|
||||||
|
|
||||||
|
/// Freezes the rendered merged list while the user is touching it, so a
|
||||||
|
/// long-press target can't scroll out from under the finger. Gates the
|
||||||
|
/// periodic [_refreshMergedMessages]; see [ChatInteractionPause].
|
||||||
|
final _interactionPause = ChatInteractionPause();
|
||||||
|
|
||||||
|
/// Hooks for the merged message list to report touch start/move/end (see [Chat]).
|
||||||
|
void onMessageListPointerDown(int pointer, Offset position) =>
|
||||||
|
_interactionPause.pointerDown(pointer, position);
|
||||||
|
void onMessageListPointerMove(int pointer, Offset position) =>
|
||||||
|
_interactionPause.pointerMove(pointer, position);
|
||||||
|
void onMessageListPointerUp(int pointer) =>
|
||||||
|
_interactionPause.pointerUp(pointer);
|
||||||
|
|
||||||
/// Frozen snapshot of merged messages when user scrolls up.
|
/// Frozen snapshot of merged messages when user scrolls up.
|
||||||
/// Prevents new messages from causing scroll jumps while reading.
|
/// Prevents new messages from causing scroll jumps while reading.
|
||||||
List<MergedMessage>? _mergedSnapshot;
|
List<MergedMessage>? _mergedSnapshot;
|
||||||
|
|
@ -184,8 +202,9 @@ abstract class ChatTabsStoreBase with Store {
|
||||||
int? tailCount,
|
int? tailCount,
|
||||||
}) {
|
}) {
|
||||||
final msgs = store.messages;
|
final msgs = store.messages;
|
||||||
final start =
|
final start = tailCount != null && msgs.length > tailCount
|
||||||
tailCount != null && msgs.length > tailCount ? msgs.length - tailCount : 0;
|
? msgs.length - tailCount
|
||||||
|
: 0;
|
||||||
for (var i = start; i < msgs.length; i++) {
|
for (var i = start; i < msgs.length; i++) {
|
||||||
final id = msgs[i].tags['id'];
|
final id = msgs[i].tags['id'];
|
||||||
if (id != null && !seenIds.add(id)) continue;
|
if (id != null && !seenIds.add(id)) continue;
|
||||||
|
|
@ -221,7 +240,12 @@ abstract class ChatTabsStoreBase with Store {
|
||||||
final seenIds = <String>{};
|
final seenIds = <String>{};
|
||||||
for (final tab in _tabs) {
|
for (final tab in _tabs) {
|
||||||
if (tab.chatStore == null) continue;
|
if (tab.chatStore == null) continue;
|
||||||
_collectMessages(tab.chatStore!, recent, seenIds, tailCount: _mergedRenderLimit);
|
_collectMessages(
|
||||||
|
tab.chatStore!,
|
||||||
|
recent,
|
||||||
|
seenIds,
|
||||||
|
tailCount: _mergedRenderLimit,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
recent.sort(_compareByTimestamp);
|
recent.sort(_compareByTimestamp);
|
||||||
if (recent.length > _mergedRenderLimit) {
|
if (recent.length > _mergedRenderLimit) {
|
||||||
|
|
@ -245,7 +269,9 @@ abstract class ChatTabsStoreBase with Store {
|
||||||
/// Refreshes the rendered merged messages. Called by the render timer.
|
/// Refreshes the rendered merged messages. Called by the render timer.
|
||||||
@action
|
@action
|
||||||
void _refreshMergedMessages() {
|
void _refreshMergedMessages() {
|
||||||
if (!_mergedAutoScroll) return;
|
// Hold the rendered list still while the user is touching it so a
|
||||||
|
// long-press target stays put for the duration of the press.
|
||||||
|
if (!_mergedAutoScroll || _interactionPause.isPaused) return;
|
||||||
_mergedRenderedMessages = _computeRecentMergedMessages();
|
_mergedRenderedMessages = _computeRecentMergedMessages();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -256,7 +282,8 @@ abstract class ChatTabsStoreBase with Store {
|
||||||
for (final tab in _tabs) {
|
for (final tab in _tabs) {
|
||||||
if (tab.chatStore != null) {
|
if (tab.chatStore != null) {
|
||||||
total +=
|
total +=
|
||||||
tab.chatStore!.messages.length + tab.chatStore!.messageBuffer.length;
|
tab.chatStore!.messages.length +
|
||||||
|
tab.chatStore!.messageBuffer.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return total;
|
return total;
|
||||||
|
|
@ -515,10 +542,9 @@ abstract class ChatTabsStoreBase with Store {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Switch to the new tab (this will activate it)
|
// Switch to the new tab, snapshotting the outgoing tab's lastSeenMessageId.
|
||||||
final newIndex = _tabs.length - 1;
|
final newIndex = _tabs.length - 1;
|
||||||
activateTab(newIndex);
|
setActiveTab(newIndex);
|
||||||
activeTabIndex = newIndex;
|
|
||||||
|
|
||||||
// If in merged mode, fetch the new tab's channel profile
|
// If in merged mode, fetch the new tab's channel profile
|
||||||
if (mergedMode) {
|
if (mergedMode) {
|
||||||
|
|
@ -620,10 +646,8 @@ abstract class ChatTabsStoreBase with Store {
|
||||||
// Primary tab (index 0) cannot be moved, and nothing can move before it
|
// Primary tab (index 0) cannot be moved, and nothing can move before it
|
||||||
if (oldIndex == 0 || newIndex == 0) return;
|
if (oldIndex == 0 || newIndex == 0) return;
|
||||||
|
|
||||||
// ReorderableListView passes newIndex as if item was already removed
|
// onReorderItem already adjusts newIndex for the removed item, so no
|
||||||
if (oldIndex < newIndex) {
|
// manual off-by-one correction is needed here.
|
||||||
newIndex -= 1;
|
|
||||||
}
|
|
||||||
if (oldIndex == newIndex) return;
|
if (oldIndex == newIndex) return;
|
||||||
|
|
||||||
final tab = _tabs.removeAt(oldIndex);
|
final tab = _tabs.removeAt(oldIndex);
|
||||||
|
|
@ -641,6 +665,30 @@ abstract class ChatTabsStoreBase with Store {
|
||||||
_syncSecondaryTabsToSettings();
|
_syncSecondaryTabsToSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the last message id in [messages] that has a non-null tag id,
|
||||||
|
/// or null if no such message exists. Skips system messages without ids.
|
||||||
|
String? _latestMessageId(List<IRCMessage> messages) {
|
||||||
|
for (var i = messages.length - 1; i >= 0; i--) {
|
||||||
|
final id = messages[i].tags['id'];
|
||||||
|
if (id != null) return id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if [index] has unread messages since the user last viewed it.
|
||||||
|
/// Always false for the active tab and in merged mode.
|
||||||
|
bool hasUnreadMessages(int index) {
|
||||||
|
if (mergedMode) return false;
|
||||||
|
if (index == activeTabIndex) return false;
|
||||||
|
if (index < 0 || index >= _tabs.length) return false;
|
||||||
|
final tab = _tabs[index];
|
||||||
|
final store = tab.chatStore;
|
||||||
|
if (store == null) return false;
|
||||||
|
final latest = _latestMessageId(store.messages);
|
||||||
|
if (latest == null) return false;
|
||||||
|
return latest != tab.lastSeenMessageId;
|
||||||
|
}
|
||||||
|
|
||||||
/// Sets the active tab to the given index.
|
/// Sets the active tab to the given index.
|
||||||
///
|
///
|
||||||
/// When [silent] is true, the current tab's draft text and reply state are
|
/// When [silent] is true, the current tab's draft text and reply state are
|
||||||
|
|
@ -649,6 +697,18 @@ abstract class ChatTabsStoreBase with Store {
|
||||||
@action
|
@action
|
||||||
void setActiveTab(int index, {bool silent = false}) {
|
void setActiveTab(int index, {bool silent = false}) {
|
||||||
if (index >= 0 && index < _tabs.length) {
|
if (index >= 0 && index < _tabs.length) {
|
||||||
|
// Snapshot the outgoing tab's most recent message id so the unread
|
||||||
|
// dot for that tab clears, and only future messages count as unread.
|
||||||
|
if (index != activeTabIndex &&
|
||||||
|
activeTabIndex >= 0 &&
|
||||||
|
activeTabIndex < _tabs.length) {
|
||||||
|
final outgoing = _tabs[activeTabIndex];
|
||||||
|
final outStore = outgoing.chatStore;
|
||||||
|
if (outStore != null) {
|
||||||
|
outgoing.lastSeenMessageId = _latestMessageId(outStore.messages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Clear text input and emote menu when switching tabs.
|
// Clear text input and emote menu when switching tabs.
|
||||||
// Skip in merged mode — tabs act as send-target selectors, not
|
// Skip in merged mode — tabs act as send-target selectors, not
|
||||||
// view switches, so draft/reply state should be preserved.
|
// view switches, so draft/reply state should be preserved.
|
||||||
|
|
@ -681,6 +741,7 @@ abstract class ChatTabsStoreBase with Store {
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_mergedRenderTimer?.cancel();
|
_mergedRenderTimer?.cancel();
|
||||||
_mergedRenderTimer = null;
|
_mergedRenderTimer = null;
|
||||||
|
_interactionPause.reset();
|
||||||
for (final tab in _tabs) {
|
for (final tab in _tabs) {
|
||||||
tab.chatStore?.dispose();
|
tab.chatStore?.dispose();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import 'package:mobx/mobx.dart';
|
||||||
|
|
||||||
|
/// Tracks user-driven spotlight/hide selections for participant channels in
|
||||||
|
/// a Twitch shared chat session (multiple broadcasters merged onto one IRC
|
||||||
|
/// connection). Pure state so it can be unit-tested without the chat store.
|
||||||
|
///
|
||||||
|
/// [ObservableSet] is reactive on its own, so [Observer] widgets reading
|
||||||
|
/// [focusedChannelIds]/[hiddenChannelIds] rebuild without any MobX codegen.
|
||||||
|
class SharedChatBubbleState {
|
||||||
|
/// Channel ids the user has spotlighted (kept unfaded). Empty means no
|
||||||
|
/// spotlight is active and nothing is faded.
|
||||||
|
final focusedChannelIds = ObservableSet<String>();
|
||||||
|
|
||||||
|
/// Channel ids whose messages are filtered out of the feed entirely.
|
||||||
|
final hiddenChannelIds = ObservableSet<String>();
|
||||||
|
|
||||||
|
/// Adds/removes [channelId] from the spotlight set. No-ops for a hidden
|
||||||
|
/// channel — its messages are filtered out, so spotlighting it would fade
|
||||||
|
/// every other channel to showcase nothing.
|
||||||
|
void toggleFocus(String channelId) {
|
||||||
|
if (hiddenChannelIds.contains(channelId)) return;
|
||||||
|
if (!focusedChannelIds.remove(channelId)) {
|
||||||
|
focusedChannelIds.add(channelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hides [channelId]'s messages and drops it from the spotlight set.
|
||||||
|
void hide(String channelId) {
|
||||||
|
hiddenChannelIds.add(channelId);
|
||||||
|
focusedChannelIds.remove(channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Un-hides [channelId]'s messages.
|
||||||
|
void show(String channelId) => hiddenChannelIds.remove(channelId);
|
||||||
|
|
||||||
|
bool isFocused(String channelId) => focusedChannelIds.contains(channelId);
|
||||||
|
|
||||||
|
bool isHidden(String channelId) => hiddenChannelIds.contains(channelId);
|
||||||
|
|
||||||
|
/// Clears both sets (e.g. when a shared chat session ends).
|
||||||
|
void reset() {
|
||||||
|
focusedChannelIds.clear();
|
||||||
|
hiddenChannelIds.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import 'package:frosty/utils/modal_bottom_sheet.dart';
|
||||||
import 'package:frosty/widgets/blurred_container.dart';
|
import 'package:frosty/widgets/blurred_container.dart';
|
||||||
import 'package:frosty/widgets/chat_input/emote_text_span_builder.dart';
|
import 'package:frosty/widgets/chat_input/emote_text_span_builder.dart';
|
||||||
import 'package:frosty/widgets/frosty_cached_network_image.dart';
|
import 'package:frosty/widgets/frosty_cached_network_image.dart';
|
||||||
|
import 'package:frosty/widgets/measure_size.dart';
|
||||||
|
|
||||||
class ChatBottomBar extends StatelessWidget {
|
class ChatBottomBar extends StatelessWidget {
|
||||||
final ChatStore chatStore;
|
final ChatStore chatStore;
|
||||||
|
|
@ -393,6 +394,13 @@ class ChatBottomBar extends StatelessWidget {
|
||||||
final needsBottomPadding =
|
final needsBottomPadding =
|
||||||
!chatStore.assetsStore.showEmoteMenu && !isHorizontalLandscape;
|
!chatStore.assetsStore.showEmoteMenu && !isHorizontalLandscape;
|
||||||
|
|
||||||
|
// Measured once here; the list pads itself by this height so the bar
|
||||||
|
// never overlaps messages. Shared by both container branches below.
|
||||||
|
final measuredBar = MeasureSize(
|
||||||
|
onChange: (size) => chatStore.setBottomBarHeight(size.height),
|
||||||
|
child: bottomBarContent,
|
||||||
|
);
|
||||||
|
|
||||||
return isFullscreenOverlay
|
return isFullscreenOverlay
|
||||||
? Padding(
|
? Padding(
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
|
|
@ -400,7 +408,7 @@ class ChatBottomBar extends StatelessWidget {
|
||||||
? MediaQuery.of(context).padding.bottom
|
? MediaQuery.of(context).padding.bottom
|
||||||
: 0,
|
: 0,
|
||||||
),
|
),
|
||||||
child: bottomBarContent,
|
child: measuredBar,
|
||||||
)
|
)
|
||||||
: BlurredContainer(
|
: BlurredContainer(
|
||||||
gradientDirection: GradientDirection.down,
|
gradientDirection: GradientDirection.down,
|
||||||
|
|
@ -409,7 +417,7 @@ class ChatBottomBar extends StatelessWidget {
|
||||||
? MediaQuery.of(context).padding.bottom
|
? MediaQuery.of(context).padding.bottom
|
||||||
: 0,
|
: 0,
|
||||||
),
|
),
|
||||||
child: bottomBarContent,
|
child: measuredBar,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import 'package:frosty/models/irc.dart';
|
||||||
import 'package:frosty/models/user.dart';
|
import 'package:frosty/models/user.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/widgets/chat_user_modal.dart';
|
import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart';
|
||||||
|
import 'package:frosty/screens/channel/chat/widgets/moderation_actions.dart';
|
||||||
import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart';
|
import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart';
|
||||||
import 'package:frosty/utils/context_extensions.dart';
|
import 'package:frosty/utils/context_extensions.dart';
|
||||||
import 'package:frosty/utils/modal_bottom_sheet.dart';
|
import 'package:frosty/utils/modal_bottom_sheet.dart';
|
||||||
|
|
@ -17,6 +18,7 @@ class ChatMessage extends StatelessWidget {
|
||||||
final IRCMessage ircMessage;
|
final IRCMessage ircMessage;
|
||||||
final ChatStore chatStore;
|
final ChatStore chatStore;
|
||||||
final bool isModal;
|
final bool isModal;
|
||||||
|
final bool forceTimestamp;
|
||||||
final bool showReplyHeader;
|
final bool showReplyHeader;
|
||||||
final bool isInReplyThread;
|
final bool isInReplyThread;
|
||||||
|
|
||||||
|
|
@ -39,6 +41,7 @@ class ChatMessage extends StatelessWidget {
|
||||||
required this.ircMessage,
|
required this.ircMessage,
|
||||||
required this.chatStore,
|
required this.chatStore,
|
||||||
this.isModal = false,
|
this.isModal = false,
|
||||||
|
this.forceTimestamp = false,
|
||||||
this.showReplyHeader = true,
|
this.showReplyHeader = true,
|
||||||
this.isInReplyThread = false,
|
this.isInReplyThread = false,
|
||||||
this.overrideChannelIdToUserTwitch,
|
this.overrideChannelIdToUserTwitch,
|
||||||
|
|
@ -65,6 +68,7 @@ class ChatMessage extends StatelessWidget {
|
||||||
username: ircMessage.user!,
|
username: ircMessage.user!,
|
||||||
userId: ircMessage.tags['user-id']!,
|
userId: ircMessage.tags['user-id']!,
|
||||||
displayName: ircMessage.tags['display-name']!,
|
displayName: ircMessage.tags['display-name']!,
|
||||||
|
onActivateSourceTab: onActivateSourceTab,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -84,6 +88,7 @@ class ChatMessage extends StatelessWidget {
|
||||||
username: user.login,
|
username: user.login,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
|
onActivateSourceTab: onActivateSourceTab,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -102,8 +107,14 @@ class ChatMessage extends StatelessWidget {
|
||||||
void onLongPressMessage(BuildContext context, TextStyle defaultTextStyle) {
|
void onLongPressMessage(BuildContext context, TextStyle defaultTextStyle) {
|
||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
|
|
||||||
|
// Allow the full menu for live messages and for ones marked deleted/cleared
|
||||||
|
// by a moderation action — those keep their tags (user-id, id), so a
|
||||||
|
// moderator can still act on them (e.g. remove a timeout). Everything else
|
||||||
|
// (notices, system messages) just copies.
|
||||||
if (ircMessage.command != Command.privateMessage &&
|
if (ircMessage.command != Command.privateMessage &&
|
||||||
ircMessage.command != Command.userState) {
|
ircMessage.command != Command.userState &&
|
||||||
|
ircMessage.command != Command.clearChat &&
|
||||||
|
ircMessage.command != Command.clearMessage) {
|
||||||
copyMessage();
|
copyMessage();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -126,7 +137,11 @@ class ChatMessage extends StatelessWidget {
|
||||||
badgeScale: chatStore.settings.badgeScale,
|
badgeScale: chatStore.settings.badgeScale,
|
||||||
launchExternal: chatStore.settings.launchUrlExternal,
|
launchExternal: chatStore.settings.launchUrlExternal,
|
||||||
timestamp: chatStore.settings.timestampType,
|
timestamp: chatStore.settings.timestampType,
|
||||||
channelIdToUserTwitch: overrideChannelIdToUserTwitch ??
|
forceTimestamp: forceTimestamp,
|
||||||
|
showHistoricalTimestamps:
|
||||||
|
chatStore.settings.showHistoricalTimestamps,
|
||||||
|
channelIdToUserTwitch:
|
||||||
|
overrideChannelIdToUserTwitch ??
|
||||||
chatStore.assetsStore.channelIdToUserTwitch,
|
chatStore.assetsStore.channelIdToUserTwitch,
|
||||||
currentChannelId:
|
currentChannelId:
|
||||||
overrideCurrentChannelId ?? chatStore.channelId,
|
overrideCurrentChannelId ?? chatStore.channelId,
|
||||||
|
|
@ -191,6 +206,14 @@ class ChatMessage extends StatelessWidget {
|
||||||
leading: const Icon(Icons.reply),
|
leading: const Icon(Icons.reply),
|
||||||
title: const Text('Reply to message'),
|
title: const Text('Reply to message'),
|
||||||
),
|
),
|
||||||
|
if (ircMessage.tags['user-id'] != null)
|
||||||
|
ModerationActions(
|
||||||
|
chatStore: chatStore,
|
||||||
|
targetUserId: ircMessage.tags['user-id']!,
|
||||||
|
targetName:
|
||||||
|
ircMessage.tags['display-name'] ?? ircMessage.user ?? '',
|
||||||
|
messageId: ircMessage.tags['id'],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -234,7 +257,11 @@ class ChatMessage extends StatelessWidget {
|
||||||
badgeScale: chatStore.settings.badgeScale,
|
badgeScale: chatStore.settings.badgeScale,
|
||||||
launchExternal: chatStore.settings.launchUrlExternal,
|
launchExternal: chatStore.settings.launchUrlExternal,
|
||||||
timestamp: chatStore.settings.timestampType,
|
timestamp: chatStore.settings.timestampType,
|
||||||
channelIdToUserTwitch: overrideChannelIdToUserTwitch ??
|
forceTimestamp: forceTimestamp,
|
||||||
|
showHistoricalTimestamps:
|
||||||
|
chatStore.settings.showHistoricalTimestamps,
|
||||||
|
channelIdToUserTwitch:
|
||||||
|
overrideChannelIdToUserTwitch ??
|
||||||
chatStore.assetsStore.channelIdToUserTwitch,
|
chatStore.assetsStore.channelIdToUserTwitch,
|
||||||
currentChannelId:
|
currentChannelId:
|
||||||
overrideCurrentChannelId ?? chatStore.channelId,
|
overrideCurrentChannelId ?? chatStore.channelId,
|
||||||
|
|
@ -389,9 +416,12 @@ class ChatMessage extends StatelessWidget {
|
||||||
},
|
},
|
||||||
launchExternal: chatStore.settings.launchUrlExternal,
|
launchExternal: chatStore.settings.launchUrlExternal,
|
||||||
timestamp: chatStore.settings.timestampType,
|
timestamp: chatStore.settings.timestampType,
|
||||||
|
forceTimestamp: forceTimestamp,
|
||||||
|
showHistoricalTimestamps:
|
||||||
|
chatStore.settings.showHistoricalTimestamps,
|
||||||
channelIdToUserTwitch:
|
channelIdToUserTwitch:
|
||||||
overrideChannelIdToUserTwitch ??
|
overrideChannelIdToUserTwitch ??
|
||||||
chatStore.assetsStore.channelIdToUserTwitch,
|
chatStore.assetsStore.channelIdToUserTwitch,
|
||||||
currentChannelId:
|
currentChannelId:
|
||||||
overrideCurrentChannelId ?? chatStore.channelId,
|
overrideCurrentChannelId ?? chatStore.channelId,
|
||||||
),
|
),
|
||||||
|
|
@ -482,10 +512,14 @@ class ChatMessage extends StatelessWidget {
|
||||||
messageHeaderIcon = Icon(
|
messageHeaderIcon = Icon(
|
||||||
Icons.campaign_rounded,
|
Icons.campaign_rounded,
|
||||||
size: messageHeaderIconSize,
|
size: messageHeaderIconSize,
|
||||||
|
color: messageHeaderTextColor,
|
||||||
);
|
);
|
||||||
messageHeader = const Text(
|
messageHeader = Text(
|
||||||
'Announcement',
|
'Announcement',
|
||||||
style: TextStyle(fontWeight: messageHeaderFontWeight),
|
style: TextStyle(
|
||||||
|
fontWeight: messageHeaderFontWeight,
|
||||||
|
color: messageHeaderTextColor,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -514,9 +548,12 @@ class ChatMessage extends StatelessWidget {
|
||||||
badgeScale: chatStore.settings.badgeScale,
|
badgeScale: chatStore.settings.badgeScale,
|
||||||
launchExternal: chatStore.settings.launchUrlExternal,
|
launchExternal: chatStore.settings.launchUrlExternal,
|
||||||
timestamp: chatStore.settings.timestampType,
|
timestamp: chatStore.settings.timestampType,
|
||||||
|
forceTimestamp: forceTimestamp,
|
||||||
|
showHistoricalTimestamps:
|
||||||
|
chatStore.settings.showHistoricalTimestamps,
|
||||||
channelIdToUserTwitch:
|
channelIdToUserTwitch:
|
||||||
overrideChannelIdToUserTwitch ??
|
overrideChannelIdToUserTwitch ??
|
||||||
chatStore.assetsStore.channelIdToUserTwitch,
|
chatStore.assetsStore.channelIdToUserTwitch,
|
||||||
currentChannelId:
|
currentChannelId:
|
||||||
overrideCurrentChannelId ?? chatStore.channelId,
|
overrideCurrentChannelId ?? chatStore.channelId,
|
||||||
),
|
),
|
||||||
|
|
@ -589,6 +626,15 @@ class ChatMessage extends StatelessWidget {
|
||||||
child: dividedMessage,
|
child: dividedMessage,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final shouldFade = chatStore.shouldFadeMessage(
|
||||||
|
sourceChannelId: ircMessage.sourceChannelId,
|
||||||
|
overrideCurrentChannelId: overrideCurrentChannelId,
|
||||||
|
);
|
||||||
|
|
||||||
|
final fadedMessage = shouldFade
|
||||||
|
? Opacity(opacity: 0.55, child: coloredMessage)
|
||||||
|
: coloredMessage;
|
||||||
|
|
||||||
final finalMessage = InkWell(
|
final finalMessage = InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
FocusScope.of(context).unfocus();
|
FocusScope.of(context).unfocus();
|
||||||
|
|
@ -597,7 +643,7 @@ class ChatMessage extends StatelessWidget {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLongPress: () => onLongPressMessage(context, defaultTextStyle),
|
onLongPress: () => onLongPressMessage(context, defaultTextStyle),
|
||||||
child: coloredMessage,
|
child: fadedMessage,
|
||||||
);
|
);
|
||||||
|
|
||||||
return finalMessage;
|
return finalMessage;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||||
|
import 'package:frosty/models/user.dart';
|
||||||
import 'package:frosty/screens/channel/chat/chat.dart';
|
import 'package:frosty/screens/channel/chat/chat.dart';
|
||||||
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
|
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
|
||||||
import 'package:frosty/screens/channel/chat/widgets/add_chat_dialog.dart';
|
import 'package:frosty/screens/channel/chat/widgets/add_chat_dialog.dart';
|
||||||
|
|
@ -38,7 +39,6 @@ class ChatTabs extends StatelessWidget {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!added && context.mounted) {
|
if (!added && context.mounted) {
|
||||||
// If not added, it means the channel already exists (switched to it)
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Channel already open, switched to it'),
|
content: Text('Channel already open, switched to it'),
|
||||||
|
|
@ -49,23 +49,36 @@ class ChatTabs extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wraps a tab bar item (a tab chip or a shared-chat bubble) with its
|
||||||
|
/// trailing gap and centering, keyed for the reorderable list.
|
||||||
|
Widget _wrapItem({required Key key, required bool isLast, required Widget child}) {
|
||||||
|
return Padding(
|
||||||
|
key: key,
|
||||||
|
padding: EdgeInsets.only(right: isLast ? 0 : 4),
|
||||||
|
child: Center(child: child),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Observer(
|
return Observer(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final tabs = chatTabsStore.tabs;
|
final tabs = chatTabsStore.tabs;
|
||||||
final activeIndex = chatTabsStore.activeTabIndex;
|
final activeIndex = chatTabsStore.activeTabIndex;
|
||||||
final showTabBar = chatTabsStore.showTabBar;
|
final activeStore = chatTabsStore.activeChatStore;
|
||||||
final showMerge =
|
// Twitch native shared chat participants (excluding the active
|
||||||
tabs.where((t) => t.isActivated).length >= 2;
|
// channel itself, which is already shown as its own tab chip).
|
||||||
|
final sharedChatParticipants = activeStore.isInSharedChatMode
|
||||||
|
? activeStore.assetsStore.channelIdToUserTwitch.entries
|
||||||
|
.where((entry) => entry.key != activeStore.channelId)
|
||||||
|
.toList()
|
||||||
|
: const <MapEntry<String, UserTwitch>>[];
|
||||||
|
final showTabBar =
|
||||||
|
chatTabsStore.showTabBar || sharedChatParticipants.isNotEmpty;
|
||||||
|
final showMerge = tabs.where((t) => t.isActivated).length >= 2;
|
||||||
|
|
||||||
// Calculate extra top padding for tab bar when visible
|
|
||||||
final tabBarHeight = showTabBar ? 48.0 : 0.0;
|
final tabBarHeight = showTabBar ? 48.0 : 0.0;
|
||||||
|
|
||||||
// Get the top inset from listPadding (e.g., for AppBar in chat-only mode)
|
|
||||||
final topInset = listPadding?.resolve(TextDirection.ltr).top ?? 0;
|
final topInset = listPadding?.resolve(TextDirection.ltr).top ?? 0;
|
||||||
|
|
||||||
// Adjust list padding to account for tab bar
|
|
||||||
final adjustedPadding = listPadding != null
|
final adjustedPadding = listPadding != null
|
||||||
? listPadding!.add(EdgeInsets.only(top: tabBarHeight))
|
? listPadding!.add(EdgeInsets.only(top: tabBarHeight))
|
||||||
: EdgeInsets.only(top: tabBarHeight);
|
: EdgeInsets.only(top: tabBarHeight);
|
||||||
|
|
@ -75,27 +88,20 @@ class ChatTabs extends StatelessWidget {
|
||||||
onPopInvokedWithResult: (didPop, _) {
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
if (didPop) return;
|
if (didPop) return;
|
||||||
|
|
||||||
// On Android back gesture, close overlays first before navigating back
|
// Android back gesture: dismiss overlays before navigating away.
|
||||||
final activeStore = chatTabsStore.activeChatStore;
|
final activeStore = chatTabsStore.activeChatStore;
|
||||||
|
|
||||||
// Priority 1: Close emote menu
|
|
||||||
if (activeStore.assetsStore.showEmoteMenu) {
|
if (activeStore.assetsStore.showEmoteMenu) {
|
||||||
activeStore.assetsStore.showEmoteMenu = false;
|
activeStore.assetsStore.showEmoteMenu = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: Unfocus keyboard
|
|
||||||
if (activeStore.textFieldFocusNode.hasFocus) {
|
if (activeStore.textFieldFocusNode.hasFocus) {
|
||||||
activeStore.unfocusInput();
|
activeStore.unfocusInput();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 3: Navigate back
|
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// Chat content: merged view or IndexedStack
|
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: chatTabsStore.mergedMode
|
child: chatTabsStore.mergedMode
|
||||||
? Chat(
|
? Chat(
|
||||||
|
|
@ -107,7 +113,6 @@ class ChatTabs extends StatelessWidget {
|
||||||
: IndexedStack(
|
: IndexedStack(
|
||||||
index: activeIndex,
|
index: activeIndex,
|
||||||
children: tabs.map((tabInfo) {
|
children: tabs.map((tabInfo) {
|
||||||
// Show placeholder for non-activated tabs
|
|
||||||
if (tabInfo.chatStore == null) {
|
if (tabInfo.chatStore == null) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
@ -120,7 +125,6 @@ class ChatTabs extends StatelessWidget {
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Tab bar (only visible when more than 1 tab)
|
|
||||||
if (showTabBar)
|
if (showTabBar)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: topInset,
|
top: topInset,
|
||||||
|
|
@ -130,8 +134,8 @@ class ChatTabs extends StatelessWidget {
|
||||||
height: 48,
|
height: 48,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// When merge button is visible, clips at its center
|
// Right-clip past the merge button so chips slide
|
||||||
// so chips slide under its left half then disappear.
|
// under its left half then disappear.
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
|
|
@ -139,12 +143,19 @@ class ChatTabs extends StatelessWidget {
|
||||||
right: showMerge ? 33 : 0,
|
right: showMerge ? 33 : 0,
|
||||||
child: ReorderableListView.builder(
|
child: ReorderableListView.builder(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
itemCount: tabs.length,
|
itemCount: tabs.length + sharedChatParticipants.length,
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
left: 12,
|
left: 12,
|
||||||
right: showMerge ? 32 : 12,
|
right: showMerge ? 32 : 12,
|
||||||
),
|
),
|
||||||
onReorder: (oldIndex, newIndex) {
|
onReorderItem: (oldIndex, newIndex) {
|
||||||
|
// Shared chat bubbles are trailing, fixed-order
|
||||||
|
// items (not real tabs) — ignore any reorder
|
||||||
|
// that touches them.
|
||||||
|
if (oldIndex >= tabs.length ||
|
||||||
|
newIndex >= tabs.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
chatTabsStore.reorderTab(oldIndex, newIndex);
|
chatTabsStore.reorderTab(oldIndex, newIndex);
|
||||||
},
|
},
|
||||||
|
|
@ -155,13 +166,30 @@ class ChatTabs extends StatelessWidget {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final tabInfo = chatTabsStore.tabs[index];
|
final isLast =
|
||||||
return Padding(
|
index ==
|
||||||
key: ValueKey(tabInfo.channelId),
|
tabs.length +
|
||||||
padding: EdgeInsets.only(
|
sharedChatParticipants.length -
|
||||||
right: index < tabs.length - 1 ? 4 : 0,
|
1;
|
||||||
|
if (index < tabs.length) {
|
||||||
|
final tabInfo = chatTabsStore.tabs[index];
|
||||||
|
return _wrapItem(
|
||||||
|
key: ValueKey(tabInfo.channelId),
|
||||||
|
isLast: isLast,
|
||||||
|
child: _buildTab(context, index),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final participant =
|
||||||
|
sharedChatParticipants[index - tabs.length];
|
||||||
|
return _wrapItem(
|
||||||
|
key: ValueKey('shared_${participant.key}'),
|
||||||
|
isLast: isLast,
|
||||||
|
child: _buildSharedChatBubble(
|
||||||
|
context,
|
||||||
|
participant.key,
|
||||||
|
participant.value,
|
||||||
),
|
),
|
||||||
child: _buildTab(context, index),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -171,7 +199,9 @@ class ChatTabs extends StatelessWidget {
|
||||||
right: 12,
|
right: 12,
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
child: Center(child: _buildMergeToggle(context)),
|
child: Center(
|
||||||
|
child: _buildMoreActionsMenu(context),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -184,22 +214,47 @@ class ChatTabs extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMergeToggle(BuildContext context) {
|
Widget _buildMoreActionsMenu(BuildContext context) {
|
||||||
final isMerged = chatTabsStore.mergedMode;
|
final settings = chatTabsStore.activeChatStore.settings;
|
||||||
return IconButton.filledTonal(
|
|
||||||
icon: const Icon(Icons.call_merge, size: 18),
|
return _AnchoredPopupMenu(
|
||||||
tooltip: isMerged ? 'Split chats' : 'Merge loaded chats',
|
itemsBuilder: (close) => [
|
||||||
visualDensity: VisualDensity.compact,
|
Observer(
|
||||||
isSelected: isMerged,
|
builder: (_) => CheckboxMenuButton(
|
||||||
style: IconButton.styleFrom(
|
value: chatTabsStore.mergedMode,
|
||||||
minimumSize: const Size(42, 42),
|
onChanged: (_) {
|
||||||
backgroundColor: isMerged
|
HapticFeedback.selectionClick();
|
||||||
? null
|
chatTabsStore.toggleMergedMode();
|
||||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
},
|
||||||
),
|
child: const Text('Merge chats'),
|
||||||
onPressed: () {
|
),
|
||||||
HapticFeedback.selectionClick();
|
),
|
||||||
chatTabsStore.toggleMergedMode();
|
Observer(
|
||||||
|
builder: (_) => CheckboxMenuButton(
|
||||||
|
value: settings.focusCurrentChannel,
|
||||||
|
onChanged: (newValue) {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
settings.focusCurrentChannel = newValue ?? false;
|
||||||
|
},
|
||||||
|
child: const Text('Focus current channel'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
anchorBuilder: (context, toggle) {
|
||||||
|
return IconButton.filledTonal(
|
||||||
|
icon: const Icon(Icons.more_vert, size: 18),
|
||||||
|
tooltip: 'Chat options',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
minimumSize: const Size(42, 42),
|
||||||
|
backgroundColor:
|
||||||
|
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
toggle();
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -207,86 +262,281 @@ class ChatTabs extends StatelessWidget {
|
||||||
Widget _buildTab(BuildContext context, int index) {
|
Widget _buildTab(BuildContext context, int index) {
|
||||||
final tabInfo = chatTabsStore.tabs[index];
|
final tabInfo = chatTabsStore.tabs[index];
|
||||||
final isActive = index == chatTabsStore.activeTabIndex;
|
final isActive = index == chatTabsStore.activeTabIndex;
|
||||||
final isActivated = tabInfo.isActivated;
|
final isSecondary = !tabInfo.isPrimary;
|
||||||
final displayName = getReadableName(
|
final displayName = getReadableName(
|
||||||
tabInfo.displayName,
|
tabInfo.displayName,
|
||||||
tabInfo.channelLogin,
|
tabInfo.channelLogin,
|
||||||
);
|
);
|
||||||
|
|
||||||
final avatar = ProfilePicture(userLogin: tabInfo.channelLogin, radius: 12);
|
return Observer(
|
||||||
|
builder: (context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isActivated = tabInfo.isActivated;
|
||||||
|
final hasUnread = chatTabsStore.hasUnreadMessages(index);
|
||||||
|
final dimmed = !isActivated || !(tabInfo.chatStore?.isConnected ?? false);
|
||||||
|
|
||||||
if (tabInfo.isPrimary) {
|
InputChip buildChip({VoidCallback? onDeleted}) => InputChip(
|
||||||
return InputChip(
|
avatar: Badge(
|
||||||
avatar: avatar,
|
smallSize: 8,
|
||||||
label: Text(displayName),
|
backgroundColor: theme.colorScheme.primary,
|
||||||
selected: isActive,
|
isLabelVisible: hasUnread,
|
||||||
showCheckmark: false,
|
child: Opacity(
|
||||||
visualDensity: VisualDensity.compact,
|
opacity: dimmed ? 0.5 : 1.0,
|
||||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
child: ProfilePicture(
|
||||||
onPressed: () {
|
userLogin: tabInfo.channelLogin,
|
||||||
if (!isActive) {
|
radius: 12,
|
||||||
HapticFeedback.selectionClick();
|
|
||||||
chatTabsStore.setActiveTab(index);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return MenuAnchor(
|
|
||||||
menuChildren: [
|
|
||||||
if (isActivated)
|
|
||||||
MenuItemButton(
|
|
||||||
leadingIcon: const Icon(Icons.power_off_rounded, size: 18),
|
|
||||||
child: const Text('Disconnect'),
|
|
||||||
onPressed: () {
|
|
||||||
HapticFeedback.lightImpact();
|
|
||||||
chatTabsStore.deactivateTab(index);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
MenuItemButton(
|
|
||||||
leadingIcon: Icon(
|
|
||||||
Icons.delete_outline_rounded,
|
|
||||||
size: 18,
|
|
||||||
color: Theme.of(context).colorScheme.error,
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'Remove',
|
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
HapticFeedback.lightImpact();
|
|
||||||
chatTabsStore.removeTab(index);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
builder: (context, controller, child) {
|
|
||||||
return InputChip(
|
|
||||||
avatar:
|
|
||||||
isActivated ? avatar : Opacity(opacity: 0.5, child: avatar),
|
|
||||||
label: Text(
|
|
||||||
displayName,
|
|
||||||
style: isActivated
|
|
||||||
? null
|
|
||||||
: TextStyle(
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.bodyMedium?.color?.withValues(alpha: 0.5),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
selected: isActive,
|
),
|
||||||
showCheckmark: false,
|
label: Text(
|
||||||
visualDensity: VisualDensity.compact,
|
displayName,
|
||||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
style: dimmed
|
||||||
onPressed: () {
|
? TextStyle(
|
||||||
if (!isActive) {
|
color: theme.textTheme.bodyMedium?.color
|
||||||
HapticFeedback.selectionClick();
|
?.withValues(alpha: 0.5),
|
||||||
chatTabsStore.setActiveTab(index);
|
)
|
||||||
}
|
: null,
|
||||||
},
|
),
|
||||||
onDeleted: () => controller.open(),
|
selected: isActive,
|
||||||
deleteButtonTooltipMessage: 'Tab options',
|
showCheckmark: false,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
onPressed: () {
|
||||||
|
if (isActive) return;
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
chatTabsStore.setActiveTab(index);
|
||||||
|
},
|
||||||
|
onDeleted: onDeleted,
|
||||||
|
deleteButtonTooltipMessage:
|
||||||
|
onDeleted != null ? 'Tab options' : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isSecondary) return buildChip();
|
||||||
|
|
||||||
|
return _AnchoredPopupMenu(
|
||||||
|
itemsBuilder: (close) => [
|
||||||
|
if (isActivated)
|
||||||
|
MenuItemButton(
|
||||||
|
leadingIcon: const Icon(Icons.power_off_rounded, size: 18),
|
||||||
|
child: const Text('Disconnect'),
|
||||||
|
onPressed: () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
close();
|
||||||
|
chatTabsStore.deactivateTab(index);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
MenuItemButton(
|
||||||
|
leadingIcon: Icon(
|
||||||
|
Icons.delete_outline_rounded,
|
||||||
|
size: 18,
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Remove',
|
||||||
|
style: TextStyle(color: theme.colorScheme.error),
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
close();
|
||||||
|
chatTabsStore.removeTab(index);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
anchorBuilder: (context, toggle) =>
|
||||||
|
buildChip(onDeleted: toggle),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a bubble for a Twitch native shared chat participant channel.
|
||||||
|
/// Unlike manual tabs, these don't own a connection: tapping spotlights
|
||||||
|
/// the channel (unfades it, fading the rest — see [chat_message.dart]),
|
||||||
|
/// and the trailing menu hides/shows the channel's messages instead of
|
||||||
|
/// disconnecting/removing a tab.
|
||||||
|
Widget _buildSharedChatBubble(
|
||||||
|
BuildContext context,
|
||||||
|
String channelId,
|
||||||
|
UserTwitch user,
|
||||||
|
) {
|
||||||
|
final activeStore = chatTabsStore.activeChatStore;
|
||||||
|
final displayName = getReadableName(user.displayName, user.login);
|
||||||
|
|
||||||
|
return Observer(
|
||||||
|
builder: (context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isFocused = activeStore.sharedChatBubbles.isFocused(channelId);
|
||||||
|
final isHidden = activeStore.sharedChatBubbles.isHidden(channelId);
|
||||||
|
|
||||||
|
InputChip buildChip({VoidCallback? onDeleted}) => InputChip(
|
||||||
|
avatar: Opacity(
|
||||||
|
opacity: isHidden ? 0.5 : 1.0,
|
||||||
|
child: ProfilePicture(userLogin: user.login, radius: 12),
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
displayName,
|
||||||
|
style: isHidden
|
||||||
|
? TextStyle(
|
||||||
|
color: theme.textTheme.bodyMedium?.color
|
||||||
|
?.withValues(alpha: 0.5),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
selected: isFocused,
|
||||||
|
showCheckmark: false,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
onPressed: () {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
activeStore.sharedChatBubbles.toggleFocus(channelId);
|
||||||
|
},
|
||||||
|
onDeleted: onDeleted,
|
||||||
|
deleteButtonTooltipMessage:
|
||||||
|
onDeleted != null ? 'Channel options' : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return _AnchoredPopupMenu(
|
||||||
|
itemsBuilder: (close) => [
|
||||||
|
MenuItemButton(
|
||||||
|
leadingIcon: Icon(
|
||||||
|
isHidden
|
||||||
|
? Icons.visibility_rounded
|
||||||
|
: Icons.visibility_off_rounded,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
child: Text(isHidden ? 'Show channel' : 'Hide channel'),
|
||||||
|
onPressed: () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
close();
|
||||||
|
if (isHidden) {
|
||||||
|
activeStore.sharedChatBubbles.show(channelId);
|
||||||
|
} else {
|
||||||
|
activeStore.sharedChatBubbles.hide(channelId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
anchorBuilder: (context, toggle) => buildChip(onDeleted: toggle),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lightweight popup menu anchored to a child widget. The menu's top-right
|
||||||
|
/// aligns to the anchor's bottom-right via [CompositedTransformFollower] and
|
||||||
|
/// fades + scales in from that corner. [MenuAnchor] doesn't expose an entry
|
||||||
|
/// animation hook in this Flutter version, so we drive one ourselves.
|
||||||
|
class _AnchoredPopupMenu extends StatefulWidget {
|
||||||
|
final Widget Function(BuildContext context, VoidCallback toggle)
|
||||||
|
anchorBuilder;
|
||||||
|
final List<Widget> Function(VoidCallback close) itemsBuilder;
|
||||||
|
|
||||||
|
const _AnchoredPopupMenu({
|
||||||
|
required this.anchorBuilder,
|
||||||
|
required this.itemsBuilder,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AnchoredPopupMenu> createState() => _AnchoredPopupMenuState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AnchoredPopupMenuState extends State<_AnchoredPopupMenu>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
final _link = LayerLink();
|
||||||
|
final _portalCtrl = OverlayPortalController();
|
||||||
|
late final _anim = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
reverseDuration: const Duration(milliseconds: 100),
|
||||||
|
vsync: this,
|
||||||
|
)..addStatusListener((status) {
|
||||||
|
if (status == AnimationStatus.dismissed && _portalCtrl.isShowing) {
|
||||||
|
_portalCtrl.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
late final _curve =
|
||||||
|
CurvedAnimation(parent: _anim, curve: Curves.easeOut);
|
||||||
|
late final _scale = Tween<double>(begin: 0.92, end: 1.0).animate(_curve);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_curve.dispose();
|
||||||
|
_anim.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggle() {
|
||||||
|
if (_portalCtrl.isShowing) {
|
||||||
|
_anim.reverse();
|
||||||
|
} else {
|
||||||
|
_portalCtrl.show();
|
||||||
|
_anim.forward();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _close() {
|
||||||
|
if (_portalCtrl.isShowing) _anim.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return CompositedTransformTarget(
|
||||||
|
link: _link,
|
||||||
|
child: OverlayPortal(
|
||||||
|
controller: _portalCtrl,
|
||||||
|
overlayChildBuilder: (overlayContext) {
|
||||||
|
final theme = Theme.of(overlayContext);
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: _close,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
CompositedTransformFollower(
|
||||||
|
link: _link,
|
||||||
|
targetAnchor: Alignment.bottomRight,
|
||||||
|
followerAnchor: Alignment.topRight,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
showWhenUnlinked: false,
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: _anim,
|
||||||
|
child: ScaleTransition(
|
||||||
|
scale: _scale,
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
child: Material(
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: const BorderRadius.all(
|
||||||
|
Radius.circular(12),
|
||||||
|
),
|
||||||
|
side: BorderSide(
|
||||||
|
color: theme.colorScheme.outlineVariant
|
||||||
|
.withValues(alpha: 0.5),
|
||||||
|
width: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: IntrinsicWidth(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: widget.itemsBuilder(_close),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: widget.anchorBuilder(context, _toggle),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
import 'package:flutter_mobx/flutter_mobx.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/widgets/chat_message.dart';
|
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
|
||||||
|
import 'package:frosty/screens/channel/chat/widgets/moderation_actions.dart';
|
||||||
import 'package:frosty/utils.dart';
|
import 'package:frosty/utils.dart';
|
||||||
import 'package:frosty/utils/modal_bottom_sheet.dart';
|
import 'package:frosty/utils/modal_bottom_sheet.dart';
|
||||||
import 'package:frosty/widgets/alert_message.dart';
|
import 'package:frosty/widgets/alert_message.dart';
|
||||||
|
|
@ -14,6 +15,7 @@ class ChatUserModal extends StatefulWidget {
|
||||||
final String username;
|
final String username;
|
||||||
final String displayName;
|
final String displayName;
|
||||||
final String userId;
|
final String userId;
|
||||||
|
final VoidCallback? onActivateSourceTab;
|
||||||
|
|
||||||
const ChatUserModal({
|
const ChatUserModal({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -21,6 +23,7 @@ class ChatUserModal extends StatefulWidget {
|
||||||
required this.username,
|
required this.username,
|
||||||
required this.displayName,
|
required this.displayName,
|
||||||
required this.userId,
|
required this.userId,
|
||||||
|
this.onActivateSourceTab,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -61,6 +64,7 @@ class _ChatUserModalState extends State<ChatUserModal> {
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Reply',
|
tooltip: 'Reply',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
widget.onActivateSourceTab?.call();
|
||||||
widget.chatStore.textController.text =
|
widget.chatStore.textController.text =
|
||||||
'@${widget.username} ';
|
'@${widget.username} ';
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
|
|
@ -77,6 +81,13 @@ class _ChatUserModalState extends State<ChatUserModal> {
|
||||||
name: name,
|
name: name,
|
||||||
userLogin: widget.username,
|
userLogin: widget.username,
|
||||||
userId: widget.userId,
|
userId: widget.userId,
|
||||||
|
// Moderator actions for this user (no messageId → no
|
||||||
|
// Delete); renders nothing for non-moderators.
|
||||||
|
moderationActions: ModerationActions(
|
||||||
|
chatStore: widget.chatStore,
|
||||||
|
targetUserId: widget.userId,
|
||||||
|
targetName: name,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
icon: Icon(Icons.adaptive.more_rounded),
|
icon: Icon(Icons.adaptive.more_rounded),
|
||||||
|
|
@ -115,6 +126,7 @@ class _ChatUserModalState extends State<ChatUserModal> {
|
||||||
ircMessage: userMessages[index],
|
ircMessage: userMessages[index],
|
||||||
chatStore: widget.chatStore,
|
chatStore: widget.chatStore,
|
||||||
isModal: true,
|
isModal: true,
|
||||||
|
forceTimestamp: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
212
lib/screens/channel/chat/widgets/moderation_actions.dart
Normal file
212
lib/screens/channel/chat/widgets/moderation_actions.dart
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||||
|
import 'package:frosty/apis/base_api_client.dart';
|
||||||
|
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
||||||
|
import 'package:frosty/screens/onboarding/login_webview.dart';
|
||||||
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
|
|
||||||
|
/// Moderator actions (delete / timeout / ban, plus unban) for [targetUserId],
|
||||||
|
/// reused by the message long-press menu and the user panel.
|
||||||
|
///
|
||||||
|
/// Renders nothing unless the viewer can moderate the channel, or should see
|
||||||
|
/// an opt-in row when IRC/broadcaster hints mod capability without OAuth scopes.
|
||||||
|
/// [messageId] enables the per-message Delete action; omit it (e.g. the user
|
||||||
|
/// panel, which isn't tied to one message) to hide Delete. The unban action
|
||||||
|
/// only appears when the user is currently banned/timed-out (seen via CLEARCHAT).
|
||||||
|
class ModerationActions extends StatelessWidget {
|
||||||
|
final ChatStore chatStore;
|
||||||
|
final String targetUserId;
|
||||||
|
|
||||||
|
/// Display name used in confirmation notifications.
|
||||||
|
final String targetName;
|
||||||
|
|
||||||
|
/// The message to delete; null hides the Delete action.
|
||||||
|
final String? messageId;
|
||||||
|
|
||||||
|
const ModerationActions({
|
||||||
|
super.key,
|
||||||
|
required this.chatStore,
|
||||||
|
required this.targetUserId,
|
||||||
|
required this.targetName,
|
||||||
|
this.messageId,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Observer(
|
||||||
|
builder: (context) {
|
||||||
|
final auth = chatStore.auth;
|
||||||
|
final hasModScopes = auth.hasModeratorScopes;
|
||||||
|
|
||||||
|
if (hasModScopes) {
|
||||||
|
if (auth.user.canPerformModeratorActions(chatStore.channelId)) {
|
||||||
|
return _actionsColumn(context);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRC/broadcaster is a CTA hint only — not authority to call Helix.
|
||||||
|
// Read userState.mod only on the no-scopes path so USERSTATE acks do
|
||||||
|
// not rebuild this sheet while real actions are already available.
|
||||||
|
final isBroadcaster = auth.user.details?.id == chatStore.channelId;
|
||||||
|
final ircModHint = chatStore.userState.mod;
|
||||||
|
if (isBroadcaster || ircModHint) {
|
||||||
|
return _enableModeratorToolsRow(context, auth);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _enableModeratorToolsRow(BuildContext context, AuthStore auth) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Divider(),
|
||||||
|
ListTile(
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
// Use the root navigator after closing the sheet.
|
||||||
|
openEnableModeratorTools(auth);
|
||||||
|
},
|
||||||
|
leading: const Icon(Icons.shield_outlined),
|
||||||
|
title: const Text('Enable moderator tools…'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Authorize Twitch permissions to delete, timeout, and ban.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _actionsColumn(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Divider(),
|
||||||
|
// Compact one-row punitive actions, ordered least- to most-destructive.
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 4, 12, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (messageId != null) ...[
|
||||||
|
Expanded(
|
||||||
|
child: _button(
|
||||||
|
Icons.delete_outline,
|
||||||
|
'Delete',
|
||||||
|
() => _run(context, _delete),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: _button(
|
||||||
|
Icons.timer_outlined,
|
||||||
|
'Timeout 10min',
|
||||||
|
() => _run(context, _timeout),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _button(
|
||||||
|
Icons.block,
|
||||||
|
'Ban',
|
||||||
|
() => _run(context, _ban),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Recovery action, separate from the punitive row to avoid mistaps and
|
||||||
|
// only shown when the user is actually banned/timed-out.
|
||||||
|
if (chatStore.isUserBannedOrTimedOut(targetUserId))
|
||||||
|
ListTile(
|
||||||
|
onTap: () => _run(context, _unban),
|
||||||
|
leading: const Icon(Icons.lock_open_rounded),
|
||||||
|
title: const Text('Remove ban or timeout'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact icon-over-label button.
|
||||||
|
Widget _button(IconData icon, String label, VoidCallback onTap) {
|
||||||
|
return OutlinedButton(
|
||||||
|
onPressed: onTap,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 20),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Text(label, style: const TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes the sheet, then runs [action], surfacing Twitch's reason on failure.
|
||||||
|
Future<void> _run(
|
||||||
|
BuildContext context,
|
||||||
|
Future<void> Function() action,
|
||||||
|
) async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
chatStore.updateNotification(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _delete() async {
|
||||||
|
final success = await chatStore.auth.user.deleteMessage(
|
||||||
|
broadcasterId: chatStore.channelId,
|
||||||
|
messageId: messageId!,
|
||||||
|
);
|
||||||
|
chatStore.updateNotification(
|
||||||
|
success ? 'Message deleted' : 'Failed to delete message',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _timeout() async {
|
||||||
|
final success = await chatStore.auth.user.banOrTimeoutUser(
|
||||||
|
broadcasterId: chatStore.channelId,
|
||||||
|
userIdToBan: targetUserId,
|
||||||
|
duration: 600, // 10 minutes
|
||||||
|
);
|
||||||
|
chatStore.updateNotification(
|
||||||
|
success
|
||||||
|
? '$targetName timed out for 10 minutes.'
|
||||||
|
: 'Failed to timeout user',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _ban() async {
|
||||||
|
final success = await chatStore.auth.user.banOrTimeoutUser(
|
||||||
|
broadcasterId: chatStore.channelId,
|
||||||
|
userIdToBan: targetUserId,
|
||||||
|
);
|
||||||
|
chatStore.updateNotification(
|
||||||
|
success ? '$targetName banned.' : 'Failed to ban user',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _unban() async {
|
||||||
|
final success = await chatStore.auth.user.unbanUser(
|
||||||
|
broadcasterId: chatStore.channelId,
|
||||||
|
userIdToUnban: targetUserId,
|
||||||
|
);
|
||||||
|
if (success) chatStore.clearUserBanState(targetUserId);
|
||||||
|
chatStore.updateNotification(
|
||||||
|
success
|
||||||
|
? 'Removed ban/timeout for $targetName.'
|
||||||
|
: 'Failed to remove ban or timeout',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -61,7 +61,7 @@ class ReplyThread extends StatelessWidget {
|
||||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'$replyName: $replyBody',
|
'$replyName: $replyBody',
|
||||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Flexible(
|
Flexible(
|
||||||
|
|
|
||||||
63
lib/screens/channel/video/channel_info_header.dart
Normal file
63
lib/screens/channel/video/channel_info_header.dart
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/video_player_interface.dart';
|
||||||
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
|
import 'package:frosty/widgets/follow_button.dart';
|
||||||
|
|
||||||
|
/// Channel metadata + follow control shown under the player (portrait).
|
||||||
|
class ChannelInfoHeader extends StatelessWidget {
|
||||||
|
final VideoPlayerInterface videoStore;
|
||||||
|
final AuthStore authStore;
|
||||||
|
final String userId;
|
||||||
|
final String displayName;
|
||||||
|
final bool isInSharedChatMode;
|
||||||
|
|
||||||
|
const ChannelInfoHeader({
|
||||||
|
super.key,
|
||||||
|
required this.videoStore,
|
||||||
|
required this.authStore,
|
||||||
|
required this.userId,
|
||||||
|
required this.displayName,
|
||||||
|
this.isInSharedChatMode = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Material(
|
||||||
|
color: Theme.of(context).scaffoldBackgroundColor,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
||||||
|
child: Observer(
|
||||||
|
builder: (context) {
|
||||||
|
final streamInfo = videoStore.streamInfo;
|
||||||
|
final offline = streamInfo == null;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: StreamInfoBar(
|
||||||
|
streamInfo: streamInfo,
|
||||||
|
offlineChannelInfo: videoStore.offlineChannelInfo,
|
||||||
|
displayName: displayName,
|
||||||
|
isCompact: true,
|
||||||
|
isOffline: offline,
|
||||||
|
isInSharedChatMode: isInSharedChatMode,
|
||||||
|
showTextShadows: false,
|
||||||
|
tooltipTriggerMode: TooltipTriggerMode.longPress,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FollowButton(
|
||||||
|
authStore: authStore,
|
||||||
|
userId: userId,
|
||||||
|
displayName: displayName,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
lib/screens/channel/video/chat_latency_sync.dart
Normal file
32
lib/screens/channel/video/chat_latency_sync.dart
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
|
||||||
|
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||||
|
|
||||||
|
/// Single owner of `SettingsStore.syncedChatDelay` writes from the video
|
||||||
|
/// player stores. Previously both stores poked the setting directly,
|
||||||
|
/// making settings an ad-hoc event bus between video and chat.
|
||||||
|
class ChatLatencySync {
|
||||||
|
ChatLatencySync({required this.settingsStore});
|
||||||
|
|
||||||
|
final SettingsStore settingsStore;
|
||||||
|
|
||||||
|
/// Reset the delay to zero on stream transitions (channel enter, stream
|
||||||
|
/// end, chat-only toggle, etc). No-op when auto-sync is disabled.
|
||||||
|
void reset() {
|
||||||
|
if (settingsStore.autoSyncChatDelay) {
|
||||||
|
settingsStore.syncedChatDelay = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push a fresh video-latency measurement. Only writes when drift from
|
||||||
|
/// the current value exceeds the tolerance, to avoid thrashing the chat
|
||||||
|
/// delay countdown on minor fluctuations.
|
||||||
|
void report(double latencySeconds) {
|
||||||
|
if (!settingsStore.autoSyncChatDelay) return;
|
||||||
|
final current = settingsStore.syncedChatDelay;
|
||||||
|
if (current == 0 ||
|
||||||
|
(latencySeconds - current).abs() >
|
||||||
|
VideoTimingConstants.chatSyncDriftToleranceSeconds) {
|
||||||
|
settingsStore.syncedChatDelay = latencySeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
lib/screens/channel/video/native_video.dart
Normal file
106
lib/screens/channel/video/native_video.dart
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import 'package:better_native_video_player/better_native_video_player.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/native_video_store.dart';
|
||||||
|
|
||||||
|
/// Native video player widget backed by AVPlayerViewController (iOS)
|
||||||
|
/// and ExoPlayer (Android).
|
||||||
|
class NativeVideo extends StatelessWidget {
|
||||||
|
final NativeVideoStore nativeVideoStore;
|
||||||
|
|
||||||
|
const NativeVideo({super.key, required this.nativeVideoStore});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Observer(
|
||||||
|
builder: (context) {
|
||||||
|
final controller = nativeVideoStore.controller;
|
||||||
|
final error = nativeVideoStore.error;
|
||||||
|
|
||||||
|
final isOffline =
|
||||||
|
!nativeVideoStore.loading &&
|
||||||
|
nativeVideoStore.streamInfo == null &&
|
||||||
|
error == null;
|
||||||
|
|
||||||
|
return ColoredBox(
|
||||||
|
color: Colors.black,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
if (controller != null && !isOffline)
|
||||||
|
NativeVideoPlayer(
|
||||||
|
key: ObjectKey(controller),
|
||||||
|
controller: controller,
|
||||||
|
),
|
||||||
|
if (controller != null && !nativeVideoStore.hasPlayedOnce && !isOffline)
|
||||||
|
const ColoredBox(
|
||||||
|
color: Colors.black,
|
||||||
|
child: SizedBox.expand(),
|
||||||
|
),
|
||||||
|
if (error != null)
|
||||||
|
Align(
|
||||||
|
alignment: const Alignment(0, 0.3),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline_rounded,
|
||||||
|
color: Colors.amber.withValues(alpha: 0.7),
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
error,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white60,
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isOffline)
|
||||||
|
const Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.tv_off_rounded,
|
||||||
|
color: Colors.white24,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (nativeVideoStore.isAudioOnlyMode && !isOffline)
|
||||||
|
const ColoredBox(
|
||||||
|
color: Colors.black,
|
||||||
|
child: SizedBox.expand(
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.headphones_rounded,
|
||||||
|
color: Colors.white24,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Audio Only',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white24,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import 'package:frosty/screens/channel/video/video_player_interface.dart';
|
||||||
|
|
||||||
|
/// Capabilities that only the native player can meaningfully support.
|
||||||
|
/// Callers that need these must type-check against this interface.
|
||||||
|
abstract class NativeVideoPlayerInterface extends VideoPlayerInterface {
|
||||||
|
/// Whether the current quality is an HLS audio-only variant.
|
||||||
|
bool get isAudioOnlyMode;
|
||||||
|
}
|
||||||
1469
lib/screens/channel/video/native_video_store.dart
Normal file
1469
lib/screens/channel/video/native_video_store.dart
Normal file
File diff suppressed because it is too large
Load diff
517
lib/screens/channel/video/native_video_store.g.dart
Normal file
517
lib/screens/channel/video/native_video_store.g.dart
Normal file
|
|
@ -0,0 +1,517 @@
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'native_video_store.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// StoreGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
|
||||||
|
|
||||||
|
mixin _$NativeVideoStore on NativeVideoStoreBase, Store {
|
||||||
|
Computed<String>? _$streamQualityComputed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get streamQuality => (_$streamQualityComputed ??= Computed<String>(
|
||||||
|
() => super.streamQuality,
|
||||||
|
name: 'NativeVideoStoreBase.streamQuality',
|
||||||
|
)).value;
|
||||||
|
|
||||||
|
late final _$_controllerAtom = Atom(
|
||||||
|
name: 'NativeVideoStoreBase._controller',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
NativeVideoPlayerController? get controller {
|
||||||
|
_$_controllerAtom.reportRead();
|
||||||
|
return super._controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
NativeVideoPlayerController? get _controller => controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
set _controller(NativeVideoPlayerController? value) {
|
||||||
|
_$_controllerAtom.reportWrite(value, super._controller, () {
|
||||||
|
super._controller = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$_loadingAtom = Atom(
|
||||||
|
name: 'NativeVideoStoreBase._loading',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
bool get loading {
|
||||||
|
_$_loadingAtom.reportRead();
|
||||||
|
return super._loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get _loading => loading;
|
||||||
|
|
||||||
|
@override
|
||||||
|
set _loading(bool value) {
|
||||||
|
_$_loadingAtom.reportWrite(value, super._loading, () {
|
||||||
|
super._loading = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$_blockingAdsAtom = Atom(
|
||||||
|
name: 'NativeVideoStoreBase._blockingAds',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
bool get blockingAds {
|
||||||
|
_$_blockingAdsAtom.reportRead();
|
||||||
|
return super._blockingAds;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get _blockingAds => blockingAds;
|
||||||
|
|
||||||
|
@override
|
||||||
|
set _blockingAds(bool value) {
|
||||||
|
_$_blockingAdsAtom.reportWrite(value, super._blockingAds, () {
|
||||||
|
super._blockingAds = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$_pausedAtom = Atom(
|
||||||
|
name: 'NativeVideoStoreBase._paused',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
bool get paused {
|
||||||
|
_$_pausedAtom.reportRead();
|
||||||
|
return super._paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get _paused => paused;
|
||||||
|
|
||||||
|
@override
|
||||||
|
set _paused(bool value) {
|
||||||
|
_$_pausedAtom.reportWrite(value, super._paused, () {
|
||||||
|
super._paused = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$_hasPlayedOnceAtom = Atom(
|
||||||
|
name: 'NativeVideoStoreBase._hasPlayedOnce',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
bool get hasPlayedOnce {
|
||||||
|
_$_hasPlayedOnceAtom.reportRead();
|
||||||
|
return super._hasPlayedOnce;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get _hasPlayedOnce => hasPlayedOnce;
|
||||||
|
|
||||||
|
@override
|
||||||
|
set _hasPlayedOnce(bool value) {
|
||||||
|
_$_hasPlayedOnceAtom.reportWrite(value, super._hasPlayedOnce, () {
|
||||||
|
super._hasPlayedOnce = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$_overlayVisibleAtom = Atom(
|
||||||
|
name: 'NativeVideoStoreBase._overlayVisible',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
bool get overlayVisible {
|
||||||
|
_$_overlayVisibleAtom.reportRead();
|
||||||
|
return super._overlayVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get _overlayVisible => overlayVisible;
|
||||||
|
|
||||||
|
@override
|
||||||
|
set _overlayVisible(bool value) {
|
||||||
|
_$_overlayVisibleAtom.reportWrite(value, super._overlayVisible, () {
|
||||||
|
super._overlayVisible = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$_streamInfoAtom = Atom(
|
||||||
|
name: 'NativeVideoStoreBase._streamInfo',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
StreamTwitch? get streamInfo {
|
||||||
|
_$_streamInfoAtom.reportRead();
|
||||||
|
return super._streamInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
StreamTwitch? get _streamInfo => streamInfo;
|
||||||
|
|
||||||
|
@override
|
||||||
|
set _streamInfo(StreamTwitch? value) {
|
||||||
|
_$_streamInfoAtom.reportWrite(value, super._streamInfo, () {
|
||||||
|
super._streamInfo = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$_offlineChannelInfoAtom = Atom(
|
||||||
|
name: 'NativeVideoStoreBase._offlineChannelInfo',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
Channel? get offlineChannelInfo {
|
||||||
|
_$_offlineChannelInfoAtom.reportRead();
|
||||||
|
return super._offlineChannelInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Channel? get _offlineChannelInfo => offlineChannelInfo;
|
||||||
|
|
||||||
|
@override
|
||||||
|
set _offlineChannelInfo(Channel? value) {
|
||||||
|
_$_offlineChannelInfoAtom.reportWrite(value, super._offlineChannelInfo, () {
|
||||||
|
super._offlineChannelInfo = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$_availableStreamQualitiesAtom = Atom(
|
||||||
|
name: 'NativeVideoStoreBase._availableStreamQualities',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
List<String> get availableStreamQualities {
|
||||||
|
_$_availableStreamQualitiesAtom.reportRead();
|
||||||
|
return super._availableStreamQualities;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get _availableStreamQualities => availableStreamQualities;
|
||||||
|
|
||||||
|
@override
|
||||||
|
set _availableStreamQualities(List<String> value) {
|
||||||
|
_$_availableStreamQualitiesAtom.reportWrite(
|
||||||
|
value,
|
||||||
|
super._availableStreamQualities,
|
||||||
|
() {
|
||||||
|
super._availableStreamQualities = value;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$_restrictedStreamQualitiesAtom = Atom(
|
||||||
|
name: 'NativeVideoStoreBase._restrictedStreamQualities',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
List<String> get restrictedStreamQualities {
|
||||||
|
_$_restrictedStreamQualitiesAtom.reportRead();
|
||||||
|
return super._restrictedStreamQualities;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get _restrictedStreamQualities => restrictedStreamQualities;
|
||||||
|
|
||||||
|
@override
|
||||||
|
set _restrictedStreamQualities(List<String> value) {
|
||||||
|
_$_restrictedStreamQualitiesAtom.reportWrite(
|
||||||
|
value,
|
||||||
|
super._restrictedStreamQualities,
|
||||||
|
() {
|
||||||
|
super._restrictedStreamQualities = value;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$_streamQualityIndexAtom = Atom(
|
||||||
|
name: 'NativeVideoStoreBase._streamQualityIndex',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
int get streamQualityIndex {
|
||||||
|
_$_streamQualityIndexAtom.reportRead();
|
||||||
|
return super._streamQualityIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get _streamQualityIndex => streamQualityIndex;
|
||||||
|
|
||||||
|
@override
|
||||||
|
set _streamQualityIndex(int value) {
|
||||||
|
_$_streamQualityIndexAtom.reportWrite(value, super._streamQualityIndex, () {
|
||||||
|
super._streamQualityIndex = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$_latencyAtom = Atom(
|
||||||
|
name: 'NativeVideoStoreBase._latency',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
String? get latency {
|
||||||
|
_$_latencyAtom.reportRead();
|
||||||
|
return super._latency;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get _latency => latency;
|
||||||
|
|
||||||
|
@override
|
||||||
|
set _latency(String? value) {
|
||||||
|
_$_latencyAtom.reportWrite(value, super._latency, () {
|
||||||
|
super._latency = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$_isInPipModeAtom = Atom(
|
||||||
|
name: 'NativeVideoStoreBase._isInPipMode',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
bool get isInPipMode {
|
||||||
|
_$_isInPipModeAtom.reportRead();
|
||||||
|
return super._isInPipMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get _isInPipMode => isInPipMode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
set _isInPipMode(bool value) {
|
||||||
|
_$_isInPipModeAtom.reportWrite(value, super._isInPipMode, () {
|
||||||
|
super._isInPipMode = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$_isAudioOnlyModeAtom = Atom(
|
||||||
|
name: 'NativeVideoStoreBase._isAudioOnlyMode',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
bool get isAudioOnlyMode {
|
||||||
|
_$_isAudioOnlyModeAtom.reportRead();
|
||||||
|
return super._isAudioOnlyMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get _isAudioOnlyMode => isAudioOnlyMode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
set _isAudioOnlyMode(bool value) {
|
||||||
|
_$_isAudioOnlyModeAtom.reportWrite(value, super._isAudioOnlyMode, () {
|
||||||
|
super._isAudioOnlyMode = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$_errorAtom = Atom(
|
||||||
|
name: 'NativeVideoStoreBase._error',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
String? get error {
|
||||||
|
_$_errorAtom.reportRead();
|
||||||
|
return super._error;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get _error => error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
set _error(String? value) {
|
||||||
|
_$_errorAtom.reportWrite(value, super._error, () {
|
||||||
|
super._error = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$_initPlayerAsyncAction = AsyncAction(
|
||||||
|
'NativeVideoStoreBase._initPlayer',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> _initPlayer() {
|
||||||
|
return _$_initPlayerAsyncAction.run(() => super._initPlayer());
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$updateStreamQualitiesAsyncAction = AsyncAction(
|
||||||
|
'NativeVideoStoreBase.updateStreamQualities',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateStreamQualities() {
|
||||||
|
return _$updateStreamQualitiesAsyncAction.run(
|
||||||
|
() => super.updateStreamQualities(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$setStreamQualityAsyncAction = AsyncAction(
|
||||||
|
'NativeVideoStoreBase.setStreamQuality',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setStreamQuality(String quality) {
|
||||||
|
return _$setStreamQualityAsyncAction.run(
|
||||||
|
() => super.setStreamQuality(quality),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$_setStreamQualityIndexAsyncAction = AsyncAction(
|
||||||
|
'NativeVideoStoreBase._setStreamQualityIndex',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> _setStreamQualityIndex(int index) {
|
||||||
|
return _$_setStreamQualityIndexAsyncAction.run(
|
||||||
|
() => super._setStreamQualityIndex(index),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$updateStreamInfoAsyncAction = AsyncAction(
|
||||||
|
'NativeVideoStoreBase.updateStreamInfo',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateStreamInfo({bool forceUpdate = false}) {
|
||||||
|
return _$updateStreamInfoAsyncAction.run(
|
||||||
|
() => super.updateStreamInfo(forceUpdate: forceUpdate),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$NativeVideoStoreBaseActionController = ActionController(
|
||||||
|
name: 'NativeVideoStoreBase',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> _reloadCurrentController() {
|
||||||
|
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
|
||||||
|
name: 'NativeVideoStoreBase._reloadCurrentController',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return super._reloadCurrentController();
|
||||||
|
} finally {
|
||||||
|
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void handleVideoTap() {
|
||||||
|
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
|
||||||
|
name: 'NativeVideoStoreBase.handleVideoTap',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return super.handleVideoTap();
|
||||||
|
} finally {
|
||||||
|
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void handleToggleOverlay() {
|
||||||
|
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
|
||||||
|
name: 'NativeVideoStoreBase.handleToggleOverlay',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return super.handleToggleOverlay();
|
||||||
|
} finally {
|
||||||
|
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> handleRefresh() {
|
||||||
|
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
|
||||||
|
name: 'NativeVideoStoreBase.handleRefresh',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return super.handleRefresh();
|
||||||
|
} finally {
|
||||||
|
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void togglePictureInPicture() {
|
||||||
|
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
|
||||||
|
name: 'NativeVideoStoreBase.togglePictureInPicture',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return super.togglePictureInPicture();
|
||||||
|
} finally {
|
||||||
|
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void handleAppResume() {
|
||||||
|
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
|
||||||
|
name: 'NativeVideoStoreBase.handleAppResume',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return super.handleAppResume();
|
||||||
|
} finally {
|
||||||
|
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void handleAndroidPipChanged(bool isInPip) {
|
||||||
|
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
|
||||||
|
name: 'NativeVideoStoreBase.handleAndroidPipChanged',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return super.handleAndroidPipChanged(isInPip);
|
||||||
|
} finally {
|
||||||
|
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
|
||||||
|
name: 'NativeVideoStoreBase.dispose',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return super.dispose();
|
||||||
|
} finally {
|
||||||
|
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void _resetPlayerStateForChatOnly() {
|
||||||
|
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
|
||||||
|
name: 'NativeVideoStoreBase._resetPlayerStateForChatOnly',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return super._resetPlayerStateForChatOnly();
|
||||||
|
} finally {
|
||||||
|
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void _initFreshController() {
|
||||||
|
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
|
||||||
|
name: 'NativeVideoStoreBase._initFreshController',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return super._initFreshController();
|
||||||
|
} finally {
|
||||||
|
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return '''
|
||||||
|
streamQuality: ${streamQuality}
|
||||||
|
''';
|
||||||
|
}
|
||||||
|
}
|
||||||
27
lib/screens/channel/video/playback_error_recovery.dart
Normal file
27
lib/screens/channel/video/playback_error_recovery.dart
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
|
||||||
|
|
||||||
|
/// Whether [NativeVideoStore] should auto-refresh after a mid-stream player
|
||||||
|
/// error instead of pinning the Reload overlay.
|
||||||
|
///
|
||||||
|
/// ExoPlayer's "Source error" (screen-off / network switch) arrives without a
|
||||||
|
/// status code, so callers must not rely on 4xx alone. [mediaServicesWereReset]
|
||||||
|
/// has its own dispose+rebuild path and is excluded here.
|
||||||
|
bool shouldAutoRecoverFromPlayerError({
|
||||||
|
required bool refreshCapReached,
|
||||||
|
String? errorCode,
|
||||||
|
}) {
|
||||||
|
if (refreshCapReached) return false;
|
||||||
|
if (errorCode == 'mediaServicesWereReset') return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delay before an automatic refresh for [statusCode].
|
||||||
|
///
|
||||||
|
/// Token/CDN 4xx can refresh immediately; network/source failures get a short
|
||||||
|
/// pause so the interface can come back after a switch or screen wake.
|
||||||
|
Duration playerErrorRetryDelay({int? statusCode}) {
|
||||||
|
if (statusCode != null && statusCode >= 400 && statusCode < 500) {
|
||||||
|
return Duration.zero;
|
||||||
|
}
|
||||||
|
return VideoTimingConstants.initRetryDelay;
|
||||||
|
}
|
||||||
86
lib/screens/channel/video/stream_info_poller.dart
Normal file
86
lib/screens/channel/video/stream_info_poller.dart
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import 'package:frosty/apis/twitch_api.dart';
|
||||||
|
import 'package:frosty/models/channel.dart';
|
||||||
|
import 'package:frosty/models/stream.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
|
||||||
|
|
||||||
|
/// Snapshot of a channel's live state.
|
||||||
|
class StreamInfoResult {
|
||||||
|
const StreamInfoResult({this.stream, this.offlineChannel});
|
||||||
|
|
||||||
|
/// Non-null when the channel is currently live.
|
||||||
|
final StreamTwitch? stream;
|
||||||
|
|
||||||
|
/// Non-null when the channel is confirmed offline (channel metadata fetched
|
||||||
|
/// successfully but no live stream). Both fields null means the fetch
|
||||||
|
/// failed entirely (network error, etc.).
|
||||||
|
final Channel? offlineChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debounced, deduplicated Twitch stream-info fetcher shared by both video
|
||||||
|
/// stores. Each store still owns its observable `_streamInfo` /
|
||||||
|
/// `_offlineChannelInfo` fields and decides how to react to the result.
|
||||||
|
class StreamInfoPoller {
|
||||||
|
StreamInfoPoller({
|
||||||
|
required this.twitchApi,
|
||||||
|
required this.userLogin,
|
||||||
|
required this.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TwitchApi twitchApi;
|
||||||
|
final String userLogin;
|
||||||
|
final String userId;
|
||||||
|
|
||||||
|
DateTime? _lastUpdate;
|
||||||
|
Future<StreamInfoResult>? _inFlight;
|
||||||
|
|
||||||
|
/// Fetches the latest stream info, returning `null` if the call was
|
||||||
|
/// debounced (caller should skip updating state). Concurrent calls await
|
||||||
|
/// the in-flight request instead of issuing a new one.
|
||||||
|
Future<StreamInfoResult?> fetch({bool forceUpdate = false}) async {
|
||||||
|
final pending = _inFlight;
|
||||||
|
if (pending != null) return pending;
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (!forceUpdate && _lastUpdate != null) {
|
||||||
|
if (now.difference(_lastUpdate!) <
|
||||||
|
VideoTimingConstants.streamInfoDebounce) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastUpdate = now;
|
||||||
|
final request = _runFetch();
|
||||||
|
_inFlight = request;
|
||||||
|
try {
|
||||||
|
return await request;
|
||||||
|
} finally {
|
||||||
|
if (identical(_inFlight, request)) _inFlight = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StreamInfoResult> _runFetch() async {
|
||||||
|
try {
|
||||||
|
final info = await twitchApi.getStream(userLogin: userLogin);
|
||||||
|
return StreamInfoResult(stream: info);
|
||||||
|
} catch (_) {
|
||||||
|
// Helix can transiently drop a live stream from /streams (or 500 on
|
||||||
|
// one endpoint while others succeed). A false "confirmed offline"
|
||||||
|
// tears down latency polling and chat delay on healthy playback, so
|
||||||
|
// require a second failed check before falling back to /channels.
|
||||||
|
try {
|
||||||
|
await Future<void>.delayed(
|
||||||
|
VideoTimingConstants.offlineConfirmationDelay,
|
||||||
|
);
|
||||||
|
final info = await twitchApi.getStream(userLogin: userLogin);
|
||||||
|
return StreamInfoResult(stream: info);
|
||||||
|
} catch (_) {
|
||||||
|
try {
|
||||||
|
final channel = await twitchApi.getChannel(userId: userId);
|
||||||
|
return StreamInfoResult(offlineChannel: channel);
|
||||||
|
} catch (_) {
|
||||||
|
return const StreamInfoResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,10 +3,12 @@ import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.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/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/channel/video/native_video_store.dart';
|
||||||
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
|
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
|
||||||
import 'package:frosty/screens/channel/video/video_store.dart';
|
import 'package:frosty/screens/channel/video/video_player_interface.dart';
|
||||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||||
import 'package:frosty/theme.dart';
|
import 'package:frosty/theme.dart';
|
||||||
import 'package:frosty/utils.dart';
|
import 'package:frosty/utils.dart';
|
||||||
|
|
@ -21,7 +23,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
/// Creates a widget containing controls which enable interactions with an underlying [Video] widget.
|
/// Creates a widget containing controls which enable interactions with an underlying [Video] widget.
|
||||||
class VideoOverlay extends StatelessWidget {
|
class VideoOverlay extends StatelessWidget {
|
||||||
final VideoStore videoStore;
|
final VideoPlayerInterface videoStore;
|
||||||
final ChatStore chatStore;
|
final ChatStore chatStore;
|
||||||
final SettingsStore settingsStore;
|
final SettingsStore settingsStore;
|
||||||
|
|
||||||
|
|
@ -88,7 +90,7 @@ class VideoOverlay extends StatelessWidget {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const SectionHeader(
|
const SectionHeader(
|
||||||
'Stream quality',
|
'Quality',
|
||||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
|
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||||
isFirst: true,
|
isFirst: true,
|
||||||
),
|
),
|
||||||
|
|
@ -97,26 +99,35 @@ class VideoOverlay extends StatelessWidget {
|
||||||
builder: (context) => ListView(
|
builder: (context) => ListView(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
primary: false,
|
primary: false,
|
||||||
children: videoStore.availableStreamQualities
|
children: [
|
||||||
.map(
|
...videoStore.availableStreamQualities.map(
|
||||||
(quality) => ListTile(
|
(quality) => ListTile(
|
||||||
trailing: videoStore.streamQuality == quality
|
trailing: videoStore.streamQuality == quality
|
||||||
? const Icon(Icons.check_rounded)
|
? const Icon(Icons.check_rounded)
|
||||||
: null,
|
: null,
|
||||||
title: Text(quality),
|
title: Text(quality),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
videoStore.setStreamQuality(quality);
|
videoStore.setStreamQuality(quality);
|
||||||
SharedPreferences.getInstance().then(
|
SharedPreferences.getInstance().then(
|
||||||
(prefs) => prefs.setString(
|
(prefs) => prefs.setString(
|
||||||
'last_stream_quality',
|
lastStreamQualityKey(videoStore.userLogin),
|
||||||
quality,
|
quality,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
.toList(),
|
// Qualities the broadcaster offers only to subscribers
|
||||||
|
// (from the playback token's restricted_bitrates).
|
||||||
|
...videoStore.restrictedStreamQualities.map(
|
||||||
|
(quality) => ListTile(
|
||||||
|
enabled: false,
|
||||||
|
title: Text(quality),
|
||||||
|
subtitle: const Text('Subscribers only'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -259,7 +270,6 @@ class VideoOverlay extends StatelessWidget {
|
||||||
displayName: chatStore.displayName,
|
displayName: chatStore.displayName,
|
||||||
showUptime: false,
|
showUptime: false,
|
||||||
showViewerCount: false,
|
showViewerCount: false,
|
||||||
showOfflineIndicator: false,
|
|
||||||
textColor: surfaceColor,
|
textColor: surfaceColor,
|
||||||
isOffline: true,
|
isOffline: true,
|
||||||
isInSharedChatMode:
|
isInSharedChatMode:
|
||||||
|
|
@ -312,6 +322,25 @@ class VideoOverlay extends StatelessWidget {
|
||||||
height: 80, // Covers bottom area around controls
|
height: 80, // Covers bottom area around controls
|
||||||
child: Container(decoration: bottomGradient),
|
child: Container(decoration: bottomGradient),
|
||||||
),
|
),
|
||||||
|
if (videoStore.isBlockingAds)
|
||||||
|
Positioned(
|
||||||
|
top: 8,
|
||||||
|
left: 8,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.8),
|
||||||
|
),
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||||||
|
child: Text(
|
||||||
|
'Blocking ads',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
// Content
|
// Content
|
||||||
Stack(
|
Stack(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -344,32 +373,36 @@ class VideoOverlay extends StatelessWidget {
|
||||||
if (videoStore.settingsStore.fullScreen &&
|
if (videoStore.settingsStore.fullScreen &&
|
||||||
context.isLandscape)
|
context.isLandscape)
|
||||||
chatOverlayButton,
|
chatOverlayButton,
|
||||||
if (!Platform.isIOS || isIPad()) videoSettingsButton,
|
if (!Platform.isIOS ||
|
||||||
|
isIPad() ||
|
||||||
|
videoStore is NativeVideoStore)
|
||||||
|
videoSettingsButton,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Center(
|
if (!videoStore.loading)
|
||||||
child: Tooltip(
|
Center(
|
||||||
message: videoStore.paused ? 'Play' : 'Pause',
|
child: Tooltip(
|
||||||
preferBelow: false,
|
message: videoStore.paused ? 'Play' : 'Pause',
|
||||||
child: IconButton(
|
preferBelow: false,
|
||||||
iconSize: 56,
|
child: IconButton(
|
||||||
icon: Icon(
|
iconSize: 56,
|
||||||
videoStore.paused
|
icon: Icon(
|
||||||
? Icons.play_arrow_rounded
|
videoStore.paused
|
||||||
: Icons.pause_rounded,
|
? Icons.play_arrow_rounded
|
||||||
color: surfaceColor,
|
: Icons.pause_rounded,
|
||||||
shadows: [
|
color: surfaceColor,
|
||||||
Shadow(
|
shadows: [
|
||||||
offset: const Offset(0, 3),
|
Shadow(
|
||||||
blurRadius: 8,
|
offset: const Offset(0, 3),
|
||||||
color: Colors.black.withValues(alpha: 0.6),
|
blurRadius: 8,
|
||||||
),
|
color: Colors.black.withValues(alpha: 0.6),
|
||||||
],
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onPressed: videoStore.handlePausePlay,
|
||||||
),
|
),
|
||||||
onPressed: videoStore.handlePausePlay,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.bottomLeft,
|
alignment: Alignment.bottomLeft,
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|
|
||||||
51
lib/screens/channel/video/video_player_interface.dart
Normal file
51
lib/screens/channel/video/video_player_interface.dart
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import 'package:frosty/models/channel.dart';
|
||||||
|
import 'package:frosty/models/stream.dart';
|
||||||
|
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||||
|
|
||||||
|
/// Abstraction over the video player store so both [VideoStore] (WebView)
|
||||||
|
/// and [NativeVideoStore] can drive the same overlay and channel UI.
|
||||||
|
///
|
||||||
|
/// Native-only capabilities live on [NativeVideoPlayerInterface].
|
||||||
|
abstract class VideoPlayerInterface {
|
||||||
|
SettingsStore get settingsStore;
|
||||||
|
|
||||||
|
/// Channel this store is driving — used as a key for per-channel
|
||||||
|
/// persistence (e.g. last-selected quality).
|
||||||
|
String get userLogin;
|
||||||
|
|
||||||
|
bool get loading;
|
||||||
|
bool get paused;
|
||||||
|
bool get overlayVisible;
|
||||||
|
bool get isInPipMode;
|
||||||
|
StreamTwitch? get streamInfo;
|
||||||
|
Channel? get offlineChannelInfo;
|
||||||
|
List<String> get availableStreamQualities;
|
||||||
|
|
||||||
|
/// Qualities that exist for this stream but require a subscription.
|
||||||
|
/// Only the native player can know this (parsed from the playback token);
|
||||||
|
/// the WebView player always reports none.
|
||||||
|
List<String> get restrictedStreamQualities;
|
||||||
|
|
||||||
|
String get streamQuality;
|
||||||
|
String? get latency;
|
||||||
|
|
||||||
|
/// True while TwitchAdSolutions is actively avoiding an ad break.
|
||||||
|
bool get isBlockingAds;
|
||||||
|
|
||||||
|
void handleVideoTap();
|
||||||
|
void handlePausePlay();
|
||||||
|
void handleToggleOverlay();
|
||||||
|
Future<void> handleRefresh();
|
||||||
|
void requestPictureInPicture();
|
||||||
|
void togglePictureInPicture();
|
||||||
|
Future<void> updateStreamQualities();
|
||||||
|
Future<void> setStreamQuality(String quality);
|
||||||
|
Future<void> updateStreamInfo({bool forceUpdate});
|
||||||
|
void handleAppResume();
|
||||||
|
|
||||||
|
/// Called from the platform PiP callbacks on Android so the store can sync
|
||||||
|
/// its overlay state, and its playback, with the OS PiP window.
|
||||||
|
void handleAndroidPipChanged(bool isInPip);
|
||||||
|
|
||||||
|
void dispose();
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -9,6 +9,26 @@ part of 'video_store.dart';
|
||||||
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
|
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
|
||||||
|
|
||||||
mixin _$VideoStore on VideoStoreBase, Store {
|
mixin _$VideoStore on VideoStoreBase, Store {
|
||||||
|
late final _$_loadingAtom = Atom(
|
||||||
|
name: 'VideoStoreBase._loading',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
bool get loading {
|
||||||
|
_$_loadingAtom.reportRead();
|
||||||
|
return super._loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get _loading => loading;
|
||||||
|
|
||||||
|
@override
|
||||||
|
set _loading(bool value) {
|
||||||
|
_$_loadingAtom.reportWrite(value, super._loading, () {
|
||||||
|
super._loading = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
late final _$_pausedAtom = Atom(
|
late final _$_pausedAtom = Atom(
|
||||||
name: 'VideoStoreBase._paused',
|
name: 'VideoStoreBase._paused',
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -258,6 +278,30 @@ mixin _$VideoStore on VideoStoreBase, Store {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void handleAndroidPipChanged(bool isInPip) {
|
||||||
|
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
|
||||||
|
name: 'VideoStoreBase.handleAndroidPipChanged',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return super.handleAndroidPipChanged(isInPip);
|
||||||
|
} finally {
|
||||||
|
_$VideoStoreBaseActionController.endAction(_$actionInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void _setPipActive(bool isInPip) {
|
||||||
|
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
|
||||||
|
name: 'VideoStoreBase._setPipActive',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return super._setPipActive(isInPip);
|
||||||
|
} finally {
|
||||||
|
_$VideoStoreBaseActionController.endAction(_$actionInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void handleAppResume() {
|
void handleAppResume() {
|
||||||
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
|
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
|
||||||
|
|
|
||||||
67
lib/screens/channel/video/video_timing_constants.dart
Normal file
67
lib/screens/channel/video/video_timing_constants.dart
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
/// Named durations and counts used by the video player stores.
|
||||||
|
/// Central owner so tuning values don't have to be hunted across files.
|
||||||
|
class VideoTimingConstants {
|
||||||
|
VideoTimingConstants._();
|
||||||
|
|
||||||
|
// Stall recovery
|
||||||
|
//
|
||||||
|
// Cross-layer ordering contract: iOS's native notification-stall recovery
|
||||||
|
// fires at ~1s, this Dart timer at 8s, and the Android native watchdog
|
||||||
|
// (VideoPlayerObserver.kt STALL_WATCHDOG_MS) at 12s. Correctness depends
|
||||||
|
// on 1s < stallDetectionDelay < 12s — Dart's attempt-1 seek must pre-empt
|
||||||
|
// the Android watchdog's heavier stop()/prepare() recovery, and iOS has
|
||||||
|
// no native watchdog so the Dart timer is the only recoverer for silent
|
||||||
|
// rate-drops there. Don't tune past either neighbor.
|
||||||
|
static const stallDetectionDelay = Duration(seconds: 8);
|
||||||
|
static const initRetryDelay = Duration(seconds: 3);
|
||||||
|
static const int maxRefreshAttempts = 3;
|
||||||
|
|
||||||
|
/// Delay between stall-recovery full refreshes, indexed by attempt number
|
||||||
|
/// (0 = first attempt). Attempts beyond the list length fall through to
|
||||||
|
/// the error path via [maxRefreshAttempts].
|
||||||
|
static const refreshBackoff = <Duration>[
|
||||||
|
Duration.zero,
|
||||||
|
Duration(seconds: 3),
|
||||||
|
Duration(seconds: 8),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// A "play" event arriving within this window after a previous one is
|
||||||
|
/// treated as a failed-recovery bounce rather than a fresh successful start.
|
||||||
|
static const shortPlayWindow = Duration(seconds: 10);
|
||||||
|
/// Number of short-play bounces required before surfacing a tailored error.
|
||||||
|
static const int shortPlayLoopThreshold = 3;
|
||||||
|
|
||||||
|
// Latency / chat sync
|
||||||
|
static const latencyPollingInterval = Duration(seconds: 3);
|
||||||
|
static const int highLatencyThresholdSeconds = 30;
|
||||||
|
static const int chatSyncDriftToleranceSeconds = 2;
|
||||||
|
|
||||||
|
/// Maximum consecutive high-latency interventions (seek/refresh) before
|
||||||
|
/// giving up on latency-driven recovery. Persistent high readings usually
|
||||||
|
/// mean device clock skew or a device that can't keep up — looping
|
||||||
|
/// recoveries forever makes playback worse, not better.
|
||||||
|
static const int maxHighLatencyRecoveries = 3;
|
||||||
|
|
||||||
|
/// How long a quality switch may suppress stall classification before the
|
||||||
|
/// flag expires. A resolution-only switch never interrupts playback, so no
|
||||||
|
/// play/pause event arrives to clear it — without this backstop the flag
|
||||||
|
/// sticks for the whole session and disables stall recovery.
|
||||||
|
static const qualitySwitchGrace = Duration(seconds: 5);
|
||||||
|
|
||||||
|
/// Delay before re-checking /streams when the first check reports offline.
|
||||||
|
/// Helix transiently drops live streams from results; a single failed
|
||||||
|
/// check isn't enough to tear down healthy playback.
|
||||||
|
static const offlineConfirmationDelay = Duration(seconds: 2);
|
||||||
|
|
||||||
|
// Overlay
|
||||||
|
static const overlayAutoHide = Duration(seconds: 5);
|
||||||
|
static const overlayQuickHide = Duration(seconds: 3);
|
||||||
|
|
||||||
|
// Stream info
|
||||||
|
static const streamInfoDebounce = Duration(seconds: 5);
|
||||||
|
static const webviewStreamInfoInterval = Duration(minutes: 1);
|
||||||
|
static const nativeStreamInfoInterval = Duration(seconds: 30);
|
||||||
|
|
||||||
|
// WebView housekeeping
|
||||||
|
static const jsCleanupInterval = Duration(minutes: 10);
|
||||||
|
}
|
||||||
|
|
@ -166,13 +166,12 @@ class _HomeState extends State<Home> {
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.favorite_border_rounded,
|
Icons.favorite_border_rounded,
|
||||||
color: _homeStore.selectedIndex == 0
|
color: theme.colorScheme.onSurfaceVariant
|
||||||
? theme.colorScheme.onSurface
|
.withValues(alpha: 0.5),
|
||||||
: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
),
|
||||||
selectedIcon: Icon(
|
selectedIcon: Icon(
|
||||||
Icons.favorite_rounded,
|
Icons.favorite_rounded,
|
||||||
color: theme.colorScheme.onSurface,
|
color: theme.colorScheme.primary,
|
||||||
),
|
),
|
||||||
label: 'Following',
|
label: 'Following',
|
||||||
tooltip: 'Following',
|
tooltip: 'Following',
|
||||||
|
|
@ -180,15 +179,12 @@ class _HomeState extends State<Home> {
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.arrow_upward_rounded,
|
Icons.arrow_upward_rounded,
|
||||||
color:
|
color: theme.colorScheme.onSurfaceVariant
|
||||||
_homeStore.selectedIndex ==
|
.withValues(alpha: 0.5),
|
||||||
(_authStore.isLoggedIn ? 1 : 0)
|
|
||||||
? theme.colorScheme.onSurface
|
|
||||||
: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
),
|
||||||
selectedIcon: Icon(
|
selectedIcon: Icon(
|
||||||
Icons.arrow_upward_rounded,
|
Icons.arrow_upward_rounded,
|
||||||
color: theme.colorScheme.onSurface,
|
color: theme.colorScheme.primary,
|
||||||
),
|
),
|
||||||
label: 'Top',
|
label: 'Top',
|
||||||
tooltip: 'Top',
|
tooltip: 'Top',
|
||||||
|
|
@ -196,15 +192,12 @@ class _HomeState extends State<Home> {
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.search_rounded,
|
Icons.search_rounded,
|
||||||
color:
|
color: theme.colorScheme.onSurfaceVariant
|
||||||
_homeStore.selectedIndex ==
|
.withValues(alpha: 0.5),
|
||||||
(_authStore.isLoggedIn ? 2 : 1)
|
|
||||||
? theme.colorScheme.onSurface
|
|
||||||
: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
),
|
||||||
selectedIcon: Icon(
|
selectedIcon: Icon(
|
||||||
Icons.search_rounded,
|
Icons.search_rounded,
|
||||||
color: theme.colorScheme.onSurface,
|
color: theme.colorScheme.primary,
|
||||||
),
|
),
|
||||||
label: 'Search',
|
label: 'Search',
|
||||||
tooltip: 'Search',
|
tooltip: 'Search',
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||||
import 'package:frosty/apis/twitch_api.dart';
|
import 'package:frosty/apis/twitch_api.dart';
|
||||||
|
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||||
import 'package:frosty/screens/home/search/search_results_categories.dart';
|
import 'package:frosty/screens/home/search/search_results_categories.dart';
|
||||||
import 'package:frosty/screens/home/search/search_results_channels.dart';
|
import 'package:frosty/screens/home/search/search_results_channels.dart';
|
||||||
|
import 'package:frosty/screens/home/search/search_results_tags.dart';
|
||||||
import 'package:frosty/screens/home/search/search_store.dart';
|
import 'package:frosty/screens/home/search/search_store.dart';
|
||||||
import 'package:frosty/screens/home/stream_list/streams_list.dart';
|
import 'package:frosty/screens/home/stream_list/streams_list.dart';
|
||||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
|
|
@ -13,7 +15,7 @@ import 'package:frosty/widgets/frosty_scrollbar.dart';
|
||||||
import 'package:frosty/widgets/section_header.dart';
|
import 'package:frosty/widgets/section_header.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
/// The search section that contians search history and search results for channels and categories.
|
/// The search section that contains search history and search results for channels and categories.
|
||||||
class Search extends StatefulWidget {
|
class Search extends StatefulWidget {
|
||||||
// The scroll controller for handling scroll to top functionality.
|
// The scroll controller for handling scroll to top functionality.
|
||||||
final ScrollController scrollController;
|
final ScrollController scrollController;
|
||||||
|
|
@ -31,6 +33,7 @@ class _SearchState extends State<Search> {
|
||||||
late final _searchStore = SearchStore(
|
late final _searchStore = SearchStore(
|
||||||
authStore: context.read<AuthStore>(),
|
authStore: context.read<AuthStore>(),
|
||||||
twitchApi: context.read<TwitchApi>(),
|
twitchApi: context.read<TwitchApi>(),
|
||||||
|
twitchGqlApi: context.read<TwitchGqlApi>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -171,6 +174,20 @@ class _SearchState extends State<Search> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SearchResultsCategories(searchStore: _searchStore),
|
SearchResultsCategories(searchStore: _searchStore),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) => SectionHeader(
|
||||||
|
'Tags',
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
16 + MediaQuery.of(context).padding.left,
|
||||||
|
8,
|
||||||
|
16 + MediaQuery.of(context).padding.right,
|
||||||
|
8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SearchResultsTags(searchStore: _searchStore),
|
||||||
// Add padding for bottom navigation bar
|
// Add padding for bottom navigation bar
|
||||||
const SliverBottomPadding(),
|
const SliverBottomPadding(),
|
||||||
],
|
],
|
||||||
|
|
@ -204,7 +221,7 @@ class _SearchState extends State<Search> {
|
||||||
onChanged: _searchStore.onSearchTextChanged,
|
onChanged: _searchStore.onSearchTextChanged,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
prefixIcon: const Icon(Icons.search_rounded),
|
prefixIcon: const Icon(Icons.search_rounded),
|
||||||
hintText: 'Search channels or categories',
|
hintText: 'Search channels, categories, or tags',
|
||||||
suffixIcon:
|
suffixIcon:
|
||||||
_searchStore.textFieldFocusNode.hasFocus ||
|
_searchStore.textFieldFocusNode.hasFocus ||
|
||||||
_searchStore.searchText.isNotEmpty
|
_searchStore.searchText.isNotEmpty
|
||||||
|
|
|
||||||
97
lib/screens/home/search/search_results_tags.dart
Normal file
97
lib/screens/home/search/search_results_tags.dart
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||||
|
import 'package:frosty/screens/home/search/search_store.dart';
|
||||||
|
import 'package:frosty/screens/home/search/tag_streams.dart';
|
||||||
|
import 'package:frosty/widgets/alert_message.dart';
|
||||||
|
import 'package:frosty/widgets/skeleton_loader.dart';
|
||||||
|
import 'package:mobx/mobx.dart';
|
||||||
|
|
||||||
|
class SearchResultsTags extends StatelessWidget {
|
||||||
|
final SearchStore searchStore;
|
||||||
|
|
||||||
|
const SearchResultsTags({super.key, required this.searchStore});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Observer(
|
||||||
|
builder: (context) {
|
||||||
|
final future = searchStore.tagFuture;
|
||||||
|
|
||||||
|
if (future == null) {
|
||||||
|
if (searchStore.isSearching) {
|
||||||
|
return _tagSkeletons();
|
||||||
|
}
|
||||||
|
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (future.status) {
|
||||||
|
case FutureStatus.pending:
|
||||||
|
return _tagSkeletons();
|
||||||
|
case FutureStatus.rejected:
|
||||||
|
return const SliverToBoxAdapter(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 100.0,
|
||||||
|
child: AlertMessage(
|
||||||
|
message: 'Unable to load tags',
|
||||||
|
vertical: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case FutureStatus.fulfilled:
|
||||||
|
final List<String> tags = future.result as List<String>? ?? const [];
|
||||||
|
|
||||||
|
if (tags.isEmpty) {
|
||||||
|
return const SliverToBoxAdapter(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 100.0,
|
||||||
|
child: AlertMessage(
|
||||||
|
message: 'No matching tags',
|
||||||
|
vertical: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SliverList.builder(
|
||||||
|
itemCount: tags.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final tag = tags[index];
|
||||||
|
return ListTile(
|
||||||
|
leading: const Icon(Icons.tag_rounded),
|
||||||
|
title: Text(tag),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
settings: const RouteSettings(
|
||||||
|
name: TagStreams.routeName,
|
||||||
|
),
|
||||||
|
builder: (context) => TagStreams(tagName: tag),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _tagSkeletons() {
|
||||||
|
return SliverList.builder(
|
||||||
|
itemCount: 4,
|
||||||
|
itemBuilder: (context, index) => ListTile(
|
||||||
|
leading: SkeletonLoader(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
title: SkeletonLoader(
|
||||||
|
height: 16,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:frosty/apis/twitch_api.dart';
|
import 'package:frosty/apis/twitch_api.dart';
|
||||||
|
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||||
import 'package:frosty/models/category.dart';
|
import 'package:frosty/models/category.dart';
|
||||||
import 'package:frosty/models/channel.dart';
|
import 'package:frosty/models/channel.dart';
|
||||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
|
|
@ -17,6 +18,8 @@ abstract class SearchStoreBase with Store {
|
||||||
|
|
||||||
final TwitchApi twitchApi;
|
final TwitchApi twitchApi;
|
||||||
|
|
||||||
|
final TwitchGqlApi twitchGqlApi;
|
||||||
|
|
||||||
final textEditingController = TextEditingController();
|
final textEditingController = TextEditingController();
|
||||||
|
|
||||||
final textFieldFocusNode = FocusNode();
|
final textFieldFocusNode = FocusNode();
|
||||||
|
|
@ -40,13 +43,20 @@ abstract class SearchStoreBase with Store {
|
||||||
@readonly
|
@readonly
|
||||||
ObservableFuture<CategoriesTwitch?>? _categoryFuture;
|
ObservableFuture<CategoriesTwitch?>? _categoryFuture;
|
||||||
|
|
||||||
SearchStoreBase({required this.authStore, required this.twitchApi}) {
|
@readonly
|
||||||
|
ObservableFuture<List<String>>? _tagFuture;
|
||||||
|
|
||||||
|
SearchStoreBase({
|
||||||
|
required this.authStore,
|
||||||
|
required this.twitchApi,
|
||||||
|
required this.twitchGqlApi,
|
||||||
|
}) {
|
||||||
init();
|
init();
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
// Retrieve the instance that will allow us to retrieve local search history.
|
// Load SharedPreferences for local search history.
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
// Retrieve the search history from local storage. If it doesn't exist, use an empty list.
|
// Retrieve the search history from local storage. If it doesn't exist, use an empty list.
|
||||||
|
|
@ -75,6 +85,7 @@ abstract class SearchStoreBase with Store {
|
||||||
_isSearching = false;
|
_isSearching = false;
|
||||||
_channelFuture = null;
|
_channelFuture = null;
|
||||||
_categoryFuture = null;
|
_categoryFuture = null;
|
||||||
|
_tagFuture = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,6 +138,8 @@ abstract class SearchStoreBase with Store {
|
||||||
}
|
}
|
||||||
return categories;
|
return categories;
|
||||||
}).asObservable();
|
}).asObservable();
|
||||||
|
|
||||||
|
_tagFuture = twitchGqlApi.searchFreeformTags(query: query).asObservable();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find a specific channel provided the [query].
|
/// Find a specific channel provided the [query].
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,26 @@ mixin _$SearchStore on SearchStoreBase, Store {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
late final _$_tagFutureAtom = Atom(
|
||||||
|
name: 'SearchStoreBase._tagFuture',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
ObservableFuture<List<String>>? get tagFuture {
|
||||||
|
_$_tagFutureAtom.reportRead();
|
||||||
|
return super._tagFuture;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
ObservableFuture<List<String>>? get _tagFuture => tagFuture;
|
||||||
|
|
||||||
|
@override
|
||||||
|
set _tagFuture(ObservableFuture<List<String>>? value) {
|
||||||
|
_$_tagFutureAtom.reportWrite(value, super._tagFuture, () {
|
||||||
|
super._tagFuture = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
late final _$initAsyncAction = AsyncAction(
|
late final _$initAsyncAction = AsyncAction(
|
||||||
'SearchStoreBase.init',
|
'SearchStoreBase.init',
|
||||||
context: context,
|
context: context,
|
||||||
|
|
|
||||||
83
lib/screens/home/search/tag_streams.dart
Normal file
83
lib/screens/home/search/tag_streams.dart
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||||
|
import 'package:frosty/screens/home/stream_list/stream_list_store.dart';
|
||||||
|
import 'package:frosty/screens/home/stream_list/streams_list.dart';
|
||||||
|
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||||
|
import 'package:frosty/widgets/blurred_container.dart';
|
||||||
|
import 'package:frosty/widgets/stream_sort_button.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
/// Streams matching a freeform tag (search → tap tag).
|
||||||
|
class TagStreams extends StatelessWidget {
|
||||||
|
static const routeName = 'TagStreams';
|
||||||
|
|
||||||
|
final String tagName;
|
||||||
|
|
||||||
|
const TagStreams({super.key, required this.tagName});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final settingsStore = context.watch<SettingsStore>();
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: theme.scaffoldBackgroundColor,
|
||||||
|
extendBody: true,
|
||||||
|
extendBodyBehindAppBar: true,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: Observer(
|
||||||
|
builder: (_) => StreamsList(
|
||||||
|
listType: ListType.tag,
|
||||||
|
freeformTag: tagName,
|
||||||
|
sortOrder: settingsStore.streamViewerSort,
|
||||||
|
showJumpButton: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: BlurredContainer(
|
||||||
|
gradientDirection: GradientDirection.up,
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
top: MediaQuery.of(context).padding.top,
|
||||||
|
left: MediaQuery.of(context).padding.left,
|
||||||
|
right: MediaQuery.of(context).padding.right,
|
||||||
|
),
|
||||||
|
child: SizedBox(
|
||||||
|
height: kToolbarHeight,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Back',
|
||||||
|
icon: Icon(Icons.adaptive.arrow_back_rounded),
|
||||||
|
onPressed: Navigator.of(context).pop,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
tagName,
|
||||||
|
style: theme.textTheme.titleLarge,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Observer(
|
||||||
|
builder: (_) => StreamSortButton(
|
||||||
|
sortOrder: settingsStore.streamViewerSort,
|
||||||
|
onChanged: (order) =>
|
||||||
|
settingsStore.streamViewerSort = order,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,8 @@ class LargeStreamCard extends StatelessWidget {
|
||||||
final bool showCategory;
|
final bool showCategory;
|
||||||
final bool showPinOption;
|
final bool showPinOption;
|
||||||
final bool? isPinned;
|
final bool? isPinned;
|
||||||
|
final bool? initialIsFollowing;
|
||||||
|
final ValueChanged<bool>? onFollowChanged;
|
||||||
|
|
||||||
const LargeStreamCard({
|
const LargeStreamCard({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -28,6 +30,8 @@ class LargeStreamCard extends StatelessWidget {
|
||||||
this.showCategory = true,
|
this.showCategory = true,
|
||||||
this.showPinOption = false,
|
this.showPinOption = false,
|
||||||
this.isPinned,
|
this.isPinned,
|
||||||
|
this.initialIsFollowing,
|
||||||
|
this.onFollowChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -95,6 +99,8 @@ class LargeStreamCard extends StatelessWidget {
|
||||||
userId: streamInfo.userId,
|
userId: streamInfo.userId,
|
||||||
showPinOption: showPinOption,
|
showPinOption: showPinOption,
|
||||||
isPinned: isPinned,
|
isPinned: isPinned,
|
||||||
|
initialIsFollowing: initialIsFollowing,
|
||||||
|
onFollowChanged: onFollowChanged,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ class OfflineChannelCard extends StatelessWidget {
|
||||||
final bool showPinOption;
|
final bool showPinOption;
|
||||||
final bool? isPinned;
|
final bool? isPinned;
|
||||||
final bool showOfflineStatus;
|
final bool showOfflineStatus;
|
||||||
|
final bool? initialIsFollowing;
|
||||||
|
final ValueChanged<bool>? onFollowChanged;
|
||||||
|
|
||||||
const OfflineChannelCard({
|
const OfflineChannelCard({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -22,6 +24,8 @@ class OfflineChannelCard extends StatelessWidget {
|
||||||
required this.showPinOption,
|
required this.showPinOption,
|
||||||
this.isPinned,
|
this.isPinned,
|
||||||
this.showOfflineStatus = false,
|
this.showOfflineStatus = false,
|
||||||
|
this.initialIsFollowing,
|
||||||
|
this.onFollowChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -77,6 +81,8 @@ class OfflineChannelCard extends StatelessWidget {
|
||||||
userId: channelInfo.broadcasterId,
|
userId: channelInfo.broadcasterId,
|
||||||
showPinOption: showPinOption,
|
showPinOption: showPinOption,
|
||||||
isPinned: isPinned,
|
isPinned: isPinned,
|
||||||
|
initialIsFollowing: initialIsFollowing,
|
||||||
|
onFollowChanged: onFollowChanged,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ class StreamCard extends StatelessWidget {
|
||||||
final bool showCategory;
|
final bool showCategory;
|
||||||
final bool showPinOption;
|
final bool showPinOption;
|
||||||
final bool? isPinned;
|
final bool? isPinned;
|
||||||
|
final bool? initialIsFollowing;
|
||||||
|
final ValueChanged<bool>? onFollowChanged;
|
||||||
|
|
||||||
const StreamCard({
|
const StreamCard({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -34,6 +36,8 @@ class StreamCard extends StatelessWidget {
|
||||||
this.showCategory = true,
|
this.showCategory = true,
|
||||||
this.showPinOption = false,
|
this.showPinOption = false,
|
||||||
this.isPinned,
|
this.isPinned,
|
||||||
|
this.initialIsFollowing,
|
||||||
|
this.onFollowChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -224,6 +228,8 @@ class StreamCard extends StatelessWidget {
|
||||||
userId: streamInfo.userId,
|
userId: streamInfo.userId,
|
||||||
showPinOption: showPinOption,
|
showPinOption: showPinOption,
|
||||||
isPinned: isPinned,
|
isPinned: isPinned,
|
||||||
|
initialIsFollowing: initialIsFollowing,
|
||||||
|
onFollowChanged: onFollowChanged,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'dart:io';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:frosty/apis/base_api_client.dart';
|
import 'package:frosty/apis/base_api_client.dart';
|
||||||
import 'package:frosty/apis/twitch_api.dart';
|
import 'package:frosty/apis/twitch_api.dart';
|
||||||
|
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||||
import 'package:frosty/models/category.dart';
|
import 'package:frosty/models/category.dart';
|
||||||
import 'package:frosty/models/followed_channel.dart';
|
import 'package:frosty/models/followed_channel.dart';
|
||||||
import 'package:frosty/models/stream.dart';
|
import 'package:frosty/models/stream.dart';
|
||||||
|
|
@ -23,19 +24,41 @@ abstract class ListStoreBase with Store {
|
||||||
/// Twitch API service class for making requests.
|
/// Twitch API service class for making requests.
|
||||||
final TwitchApi twitchApi;
|
final TwitchApi twitchApi;
|
||||||
|
|
||||||
|
/// GQL client for tag streams and ascending viewer sort (Helix has neither).
|
||||||
|
final TwitchGqlApi? twitchGqlApi;
|
||||||
|
|
||||||
/// The type of list that this store is handling.
|
/// The type of list that this store is handling.
|
||||||
final ListType listType;
|
final ListType listType;
|
||||||
|
|
||||||
/// The category id to use when fetching streams if the [listType] is [ListType.category].
|
/// The category id to use when fetching streams if the [listType] is [ListType.category].
|
||||||
final String? categoryId;
|
final String? categoryId;
|
||||||
|
|
||||||
|
/// Freeform tag name when [listType] is [ListType.tag].
|
||||||
|
final String? freeformTag;
|
||||||
|
|
||||||
|
/// Viewer sort for top / category / tag lists.
|
||||||
|
@observable
|
||||||
|
StreamViewerSort sortOrder;
|
||||||
|
|
||||||
/// The scroll controller used for handling scroll to top (if provided).
|
/// The scroll controller used for handling scroll to top (if provided).
|
||||||
/// If provided, will use the [ScrollToTop] widget to scroll to the top of the list instead of the bottom tab bar.
|
/// If provided, will use the [ScrollToTop] widget to scroll to the top of the list instead of the bottom tab bar.
|
||||||
final ScrollController? scrollController;
|
final ScrollController? scrollController;
|
||||||
|
|
||||||
|
/// When true, [dispose] will dispose [scrollController]. Shared controllers
|
||||||
|
/// from Home must not be disposed here.
|
||||||
|
final bool ownsScrollController;
|
||||||
|
|
||||||
/// The pagination cursor for the streams.
|
/// The pagination cursor for the streams.
|
||||||
String? _streamsCursor;
|
String? _streamsCursor;
|
||||||
|
|
||||||
|
/// When true, pages come from Helix (GQL cursor/integrity unavailable).
|
||||||
|
/// ASC Helix fill starts from the top; duplicates are skipped and the list
|
||||||
|
/// is re-sorted client-side.
|
||||||
|
var _helixFill = false;
|
||||||
|
|
||||||
|
/// Last page was served by GQL with server-side sort — append in API order.
|
||||||
|
var _lastPageWasGql = false;
|
||||||
|
|
||||||
/// The pagination cursor for the offline followed channels.
|
/// The pagination cursor for the offline followed channels.
|
||||||
String? _offlineChannelsCursor;
|
String? _offlineChannelsCursor;
|
||||||
|
|
||||||
|
|
@ -44,7 +67,8 @@ abstract class ListStoreBase with Store {
|
||||||
|
|
||||||
/// Returns whether or not there are more streams and loading status for pagination.
|
/// Returns whether or not there are more streams and loading status for pagination.
|
||||||
@computed
|
@computed
|
||||||
bool get hasMore => isLoading == false && _streamsCursor != null;
|
bool get hasMore =>
|
||||||
|
isLoading == false && (_streamsCursor != null || _helixFill);
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
bool get isLoading =>
|
bool get isLoading =>
|
||||||
|
|
@ -53,6 +77,12 @@ abstract class ListStoreBase with Store {
|
||||||
_isCategoryDetailsLoading ||
|
_isCategoryDetailsLoading ||
|
||||||
_isOfflineChannelsLoading;
|
_isOfflineChannelsLoading;
|
||||||
|
|
||||||
|
/// Selected stream language codes from settings (lowercase ISO 639-1).
|
||||||
|
List<String> get _languageFilter => settingsStore.streamLanguages
|
||||||
|
.map((l) => l.trim().toLowerCase())
|
||||||
|
.where((l) => l.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
/// The loading status for pagination.
|
/// The loading status for pagination.
|
||||||
|
|
||||||
/// The list of the fetched streams.
|
/// The list of the fetched streams.
|
||||||
|
|
@ -145,24 +175,22 @@ abstract class ListStoreBase with Store {
|
||||||
|
|
||||||
ReactionDisposer? _pinnedStreamsReactionDisposer;
|
ReactionDisposer? _pinnedStreamsReactionDisposer;
|
||||||
ReactionDisposer? _offlineChannelsExpansionDisposer;
|
ReactionDisposer? _offlineChannelsExpansionDisposer;
|
||||||
|
ReactionDisposer? _languageFilterReactionDisposer;
|
||||||
|
|
||||||
ListStoreBase({
|
ListStoreBase({
|
||||||
required this.authStore,
|
required this.authStore,
|
||||||
required this.settingsStore,
|
required this.settingsStore,
|
||||||
required this.twitchApi,
|
required this.twitchApi,
|
||||||
required this.listType,
|
required this.listType,
|
||||||
|
this.twitchGqlApi,
|
||||||
this.categoryId,
|
this.categoryId,
|
||||||
|
this.freeformTag,
|
||||||
|
this.sortOrder = StreamViewerSort.viewersDesc,
|
||||||
this.scrollController,
|
this.scrollController,
|
||||||
|
this.ownsScrollController = false,
|
||||||
}) {
|
}) {
|
||||||
if (scrollController != null) {
|
if (scrollController != null) {
|
||||||
scrollController!.addListener(() {
|
scrollController!.addListener(_onScroll);
|
||||||
if (scrollController!.position.atEdge ||
|
|
||||||
scrollController!.position.outOfRange) {
|
|
||||||
showJumpButton = false;
|
|
||||||
} else {
|
|
||||||
showJumpButton = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (listType == ListType.followed) {
|
if (listType == ListType.followed) {
|
||||||
|
|
@ -195,54 +223,241 @@ abstract class ListStoreBase with Store {
|
||||||
_getCategoryDetails();
|
_getCategoryDetails();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reload directory lists when the language filter or sort changes.
|
||||||
|
if (listType != ListType.followed) {
|
||||||
|
sortOrder = settingsStore.streamViewerSort;
|
||||||
|
_languageFilterReactionDisposer = reaction(
|
||||||
|
(_) => [
|
||||||
|
...settingsStore.streamLanguages,
|
||||||
|
settingsStore.streamViewerSort,
|
||||||
|
],
|
||||||
|
(_) {
|
||||||
|
sortOrder = settingsStore.streamViewerSort;
|
||||||
|
refreshStreams();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
getStreams();
|
getStreams();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
final controller = scrollController;
|
||||||
|
if (controller == null || !controller.hasClients) return;
|
||||||
|
|
||||||
|
final position = controller.position;
|
||||||
|
if (position.atEdge || position.outOfRange) {
|
||||||
|
showJumpButton = false;
|
||||||
|
} else {
|
||||||
|
showJumpButton = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup pagination trigger (in addition to SliverList near-end builds).
|
||||||
|
if (hasMore &&
|
||||||
|
position.pixels >= position.maxScrollExtent - 800) {
|
||||||
|
getStreams();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefer GQL for tag directories (both sorts) and for ascending Top/category.
|
||||||
|
bool get _preferGqlDirectory {
|
||||||
|
if (twitchGqlApi == null || _helixFill) return false;
|
||||||
|
if (listType == ListType.tag) return true;
|
||||||
|
return listType != ListType.followed &&
|
||||||
|
sortOrder == StreamViewerSort.viewersAsc;
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetches the streams based on the type and current cursor.
|
/// Fetches the streams based on the type and current cursor.
|
||||||
@action
|
@action
|
||||||
Future<void> getStreams() async {
|
Future<void> getStreams() async {
|
||||||
|
if (_isAllStreamsLoading) return;
|
||||||
|
|
||||||
_isAllStreamsLoading = true;
|
_isAllStreamsLoading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final StreamsTwitch newStreams;
|
final languages = _languageFilter;
|
||||||
switch (listType) {
|
final isAsc = sortOrder == StreamViewerSort.viewersAsc;
|
||||||
case ListType.followed:
|
final useGql = _preferGqlDirectory;
|
||||||
newStreams = await twitchApi.getFollowedStreams(
|
_lastPageWasGql = false;
|
||||||
id: authStore.user.details!.id,
|
|
||||||
cursor: _streamsCursor,
|
StreamsTwitch? newStreams;
|
||||||
);
|
if (listType == ListType.followed) {
|
||||||
break;
|
newStreams = await twitchApi.getFollowedStreams(
|
||||||
case ListType.top:
|
id: authStore.user.details!.id,
|
||||||
newStreams = await twitchApi.getTopStreams(cursor: _streamsCursor);
|
cursor: _streamsCursor,
|
||||||
break;
|
);
|
||||||
case ListType.category:
|
} else if (useGql) {
|
||||||
newStreams = await twitchApi.getStreamsUnderCategory(
|
newStreams = await _fetchGqlDirectory(languages: languages);
|
||||||
gameId: categoryId!,
|
if (newStreams != null) {
|
||||||
cursor: _streamsCursor,
|
_lastPageWasGql = true;
|
||||||
);
|
}
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_streamsCursor == null) {
|
// Helix: Top/category DESC, or GQL unavailable / integrity fallback.
|
||||||
_allStreams = newStreams.data.asObservable();
|
newStreams ??= await twitchApi.getDirectoryStreams(
|
||||||
} else {
|
languages: languages,
|
||||||
_allStreams.addAll(newStreams.data);
|
gameId: listType == ListType.category ? categoryId : null,
|
||||||
|
freeformTag: listType == ListType.tag ? freeformTag : null,
|
||||||
|
cursor: _streamsCursor,
|
||||||
|
minCount: listType == ListType.tag ? 40 : (isAsc ? 30 : 50),
|
||||||
|
maxPages: listType == ListType.tag ? 25 : (isAsc ? 20 : 12),
|
||||||
|
);
|
||||||
|
|
||||||
|
final next = newStreams.pagination['cursor'];
|
||||||
|
final hasCursor = next != null && next.isNotEmpty;
|
||||||
|
|
||||||
|
// Empty Helix window with cursor left ⇒ keep scanning (sparse tags).
|
||||||
|
final isHelixContinuation = !_lastPageWasGql &&
|
||||||
|
(_streamsCursor != null ||
|
||||||
|
(_helixFill && _allStreams.isNotEmpty));
|
||||||
|
if (isHelixContinuation && newStreams.data.isEmpty) {
|
||||||
|
if (hasCursor) {
|
||||||
|
_streamsCursor = next;
|
||||||
|
_error = null;
|
||||||
|
_isAllStreamsLoading = false;
|
||||||
|
await getStreams();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_streamsCursor = null;
|
||||||
|
_helixFill = false;
|
||||||
|
_error = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GQL pages are already server-sorted — keep API order.
|
||||||
|
// Helix ASC fill: sort page + full list so late matches land correctly.
|
||||||
|
final pageData = (!_lastPageWasGql && isAsc)
|
||||||
|
? (List<StreamTwitch>.of(newStreams.data)..sort(
|
||||||
|
(a, b) => a.viewerCount.compareTo(b.viewerCount),
|
||||||
|
))
|
||||||
|
: newStreams.data;
|
||||||
|
|
||||||
|
if (_allStreams.isEmpty && !_helixFill) {
|
||||||
|
_allStreams = pageData.asObservable();
|
||||||
|
} else {
|
||||||
|
final existingIds = _allStreams.map((s) => s.userId).toSet();
|
||||||
|
_allStreams.addAll(
|
||||||
|
pageData.where((s) => !existingIds.contains(s.userId)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_lastPageWasGql && isAsc && _allStreams.length > 1) {
|
||||||
|
final sorted = _allStreams.toList()
|
||||||
|
..sort((a, b) => a.viewerCount.compareTo(b.viewerCount));
|
||||||
|
_allStreams = sorted.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
_streamsCursor = hasCursor ? next : null;
|
||||||
|
if (_streamsCursor == null) {
|
||||||
|
_helixFill = false;
|
||||||
|
} else if (!_lastPageWasGql && isAsc) {
|
||||||
|
_helixFill = true;
|
||||||
}
|
}
|
||||||
_streamsCursor = newStreams.pagination['cursor'];
|
|
||||||
|
|
||||||
_error = null;
|
_error = null;
|
||||||
} on SocketException {
|
} on SocketException {
|
||||||
_error = 'Unable to connect to Twitch';
|
_error = 'Unable to connect to Twitch';
|
||||||
debugPrint('Streams SocketException: No internet connection');
|
debugPrint('Streams SocketException: No internet connection');
|
||||||
|
if (_streamsCursor != null && _allStreams.isNotEmpty) {
|
||||||
|
_streamsCursor = null;
|
||||||
|
_helixFill = false;
|
||||||
|
}
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
_error = e.message;
|
_error = e.message;
|
||||||
debugPrint('Streams ApiException: $e');
|
debugPrint('Streams ApiException: $e');
|
||||||
|
if (_allStreams.isNotEmpty) {
|
||||||
|
// Mid-scroll GQL failure: fall back to Helix fill when useful.
|
||||||
|
if (!_helixFill &&
|
||||||
|
(listType == ListType.tag || isAscDirectory)) {
|
||||||
|
_helixFill = true;
|
||||||
|
_streamsCursor = null;
|
||||||
|
_error = null;
|
||||||
|
_isAllStreamsLoading = false;
|
||||||
|
await getStreams();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_streamsCursor = null;
|
||||||
|
_helixFill = false;
|
||||||
|
_error = null;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_error = 'Something went wrong loading streams';
|
_error = 'Something went wrong loading streams';
|
||||||
debugPrint('Streams error: $e');
|
debugPrint('Streams error: $e');
|
||||||
|
if (_streamsCursor != null && _allStreams.isNotEmpty) {
|
||||||
|
_streamsCursor = null;
|
||||||
|
_helixFill = false;
|
||||||
|
_error = null;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_isAllStreamsLoading = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_isAllStreamsLoading = false;
|
/// GQL directory page with integrity retry. Returns null to trigger Helix.
|
||||||
|
Future<StreamsTwitch?> _fetchGqlDirectory({
|
||||||
|
required List<String> languages,
|
||||||
|
}) async {
|
||||||
|
final gql = twitchGqlApi;
|
||||||
|
if (gql == null) return null;
|
||||||
|
|
||||||
|
final langs = languages.isEmpty ? null : languages;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await authStore.user.runWithGqlIntegrity(
|
||||||
|
gqlToken: authStore.gqlToken,
|
||||||
|
action: ({authToken, integrityToken, deviceId}) {
|
||||||
|
switch (listType) {
|
||||||
|
case ListType.tag:
|
||||||
|
return gql.getStreams(
|
||||||
|
freeformTags: [freeformTag!],
|
||||||
|
sort: sortOrder,
|
||||||
|
languages: langs,
|
||||||
|
cursor: _streamsCursor,
|
||||||
|
authToken: authToken,
|
||||||
|
integrityToken: integrityToken,
|
||||||
|
deviceId: deviceId,
|
||||||
|
);
|
||||||
|
case ListType.category:
|
||||||
|
return gql.getGameStreams(
|
||||||
|
gameId: categoryId!,
|
||||||
|
sort: sortOrder,
|
||||||
|
languages: langs,
|
||||||
|
cursor: _streamsCursor,
|
||||||
|
authToken: authToken,
|
||||||
|
integrityToken: integrityToken,
|
||||||
|
deviceId: deviceId,
|
||||||
|
);
|
||||||
|
case ListType.top:
|
||||||
|
return gql.getStreams(
|
||||||
|
sort: sortOrder,
|
||||||
|
languages: langs,
|
||||||
|
cursor: _streamsCursor,
|
||||||
|
authToken: authToken,
|
||||||
|
integrityToken: integrityToken,
|
||||||
|
deviceId: deviceId,
|
||||||
|
);
|
||||||
|
case ListType.followed:
|
||||||
|
throw StateError('GQL directory not used for followed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} on IntegrityCheckException catch (e) {
|
||||||
|
debugPrint('Streams GQL integrity: $e — falling back to Helix');
|
||||||
|
_helixFill = true;
|
||||||
|
// Keep cursor null so Helix starts from the top (dedupe handles overlap).
|
||||||
|
_streamsCursor = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isAscDirectory =>
|
||||||
|
sortOrder == StreamViewerSort.viewersAsc &&
|
||||||
|
listType != ListType.followed;
|
||||||
|
|
||||||
|
/// Persists viewer sort and reloads via the settings reaction.
|
||||||
|
@action
|
||||||
|
Future<void> setSortOrder(StreamViewerSort order) async {
|
||||||
|
settingsStore.streamViewerSort = order;
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
|
|
@ -320,6 +535,8 @@ abstract class ListStoreBase with Store {
|
||||||
}
|
}
|
||||||
|
|
||||||
_streamsCursor = null;
|
_streamsCursor = null;
|
||||||
|
_helixFill = false;
|
||||||
|
_lastPageWasGql = false;
|
||||||
await getStreams();
|
await getStreams();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -349,8 +566,14 @@ abstract class ListStoreBase with Store {
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_pinnedStreamsReactionDisposer?.call();
|
_pinnedStreamsReactionDisposer?.call();
|
||||||
_offlineChannelsExpansionDisposer?.call();
|
_offlineChannelsExpansionDisposer?.call();
|
||||||
|
_languageFilterReactionDisposer?.call();
|
||||||
|
|
||||||
scrollController?.dispose();
|
if (scrollController != null) {
|
||||||
|
scrollController!.removeListener(_onScroll);
|
||||||
|
if (ownsScrollController) {
|
||||||
|
scrollController!.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -359,4 +582,5 @@ abstract class ListStoreBase with Store {
|
||||||
/// [ListType.followed] is the list of streams that the user is following.
|
/// [ListType.followed] is the list of streams that the user is following.
|
||||||
/// [ListType.top] is the list of top streams.
|
/// [ListType.top] is the list of top streams.
|
||||||
/// [ListType.category] is the list of streams under a category.
|
/// [ListType.category] is the list of streams under a category.
|
||||||
enum ListType { followed, top, category }
|
/// [ListType.tag] is streams matching a freeform tag (GQL sort + tags; Helix fallback).
|
||||||
|
enum ListType { followed, top, category, tag }
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,24 @@ mixin _$ListStore on ListStoreBase, Store {
|
||||||
name: 'ListStoreBase.offlineChannels',
|
name: 'ListStoreBase.offlineChannels',
|
||||||
)).value;
|
)).value;
|
||||||
|
|
||||||
|
late final _$sortOrderAtom = Atom(
|
||||||
|
name: 'ListStoreBase.sortOrder',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
StreamViewerSort get sortOrder {
|
||||||
|
_$sortOrderAtom.reportRead();
|
||||||
|
return super.sortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
set sortOrder(StreamViewerSort value) {
|
||||||
|
_$sortOrderAtom.reportWrite(value, super.sortOrder, () {
|
||||||
|
super.sortOrder = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
late final _$_allStreamsAtom = Atom(
|
late final _$_allStreamsAtom = Atom(
|
||||||
name: 'ListStoreBase._allStreams',
|
name: 'ListStoreBase._allStreams',
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -302,6 +320,16 @@ mixin _$ListStore on ListStoreBase, Store {
|
||||||
return _$getStreamsAsyncAction.run(() => super.getStreams());
|
return _$getStreamsAsyncAction.run(() => super.getStreams());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
late final _$setSortOrderAsyncAction = AsyncAction(
|
||||||
|
'ListStoreBase.setSortOrder',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setSortOrder(StreamViewerSort order) {
|
||||||
|
return _$setSortOrderAsyncAction.run(() => super.setSortOrder(order));
|
||||||
|
}
|
||||||
|
|
||||||
late final _$getPinnedStreamsAsyncAction = AsyncAction(
|
late final _$getPinnedStreamsAsyncAction = AsyncAction(
|
||||||
'ListStoreBase.getPinnedStreams',
|
'ListStoreBase.getPinnedStreams',
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -349,6 +377,7 @@ mixin _$ListStore on ListStoreBase, Store {
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return '''
|
return '''
|
||||||
|
sortOrder: ${sortOrder},
|
||||||
showJumpButton: ${showJumpButton},
|
showJumpButton: ${showJumpButton},
|
||||||
isOfflineChannelsExpanded: ${isOfflineChannelsExpanded},
|
isOfflineChannelsExpanded: ${isOfflineChannelsExpanded},
|
||||||
hasMore: ${hasMore},
|
hasMore: ${hasMore},
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||||
import 'package:frosty/apis/twitch_api.dart';
|
import 'package:frosty/apis/twitch_api.dart';
|
||||||
|
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||||
import 'package:frosty/models/stream.dart';
|
import 'package:frosty/models/stream.dart';
|
||||||
import 'package:frosty/screens/home/stream_list/large_stream_card.dart';
|
import 'package:frosty/screens/home/stream_list/large_stream_card.dart';
|
||||||
import 'package:frosty/screens/home/stream_list/offline_channel_card.dart';
|
import 'package:frosty/screens/home/stream_list/offline_channel_card.dart';
|
||||||
|
|
@ -25,6 +26,12 @@ class StreamsList extends StatefulWidget {
|
||||||
|
|
||||||
final String? categoryId;
|
final String? categoryId;
|
||||||
|
|
||||||
|
/// Freeform tag when [listType] is [ListType.tag].
|
||||||
|
final String? freeformTag;
|
||||||
|
|
||||||
|
/// Viewer sort for top / category / tag lists.
|
||||||
|
final StreamViewerSort sortOrder;
|
||||||
|
|
||||||
/// The scroll controller to use for scroll to top functionality.
|
/// The scroll controller to use for scroll to top functionality.
|
||||||
final ScrollController? scrollController;
|
final ScrollController? scrollController;
|
||||||
|
|
||||||
|
|
@ -34,6 +41,8 @@ class StreamsList extends StatefulWidget {
|
||||||
super.key,
|
super.key,
|
||||||
required this.listType,
|
required this.listType,
|
||||||
this.categoryId,
|
this.categoryId,
|
||||||
|
this.freeformTag,
|
||||||
|
this.sortOrder = StreamViewerSort.viewersDesc,
|
||||||
this.scrollController,
|
this.scrollController,
|
||||||
this.showJumpButton = false,
|
this.showJumpButton = false,
|
||||||
});
|
});
|
||||||
|
|
@ -63,16 +72,24 @@ class _StreamsListState extends State<StreamsList>
|
||||||
authStore: context.read<AuthStore>(),
|
authStore: context.read<AuthStore>(),
|
||||||
settingsStore: context.read<SettingsStore>(),
|
settingsStore: context.read<SettingsStore>(),
|
||||||
twitchApi: context.read<TwitchApi>(),
|
twitchApi: context.read<TwitchApi>(),
|
||||||
|
twitchGqlApi: context.read<TwitchGqlApi>(),
|
||||||
listType: widget.listType,
|
listType: widget.listType,
|
||||||
categoryId: widget.categoryId,
|
categoryId: widget.categoryId,
|
||||||
|
freeformTag: widget.freeformTag,
|
||||||
|
sortOrder: widget.sortOrder,
|
||||||
scrollController: widget.scrollController ?? _scrollController,
|
scrollController: widget.scrollController ?? _scrollController,
|
||||||
|
ownsScrollController: widget.scrollController == null && widget.showJumpButton,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_scrollController?.dispose();
|
// ListStore disposes the controller when it owns it.
|
||||||
|
if (!_listStore.ownsScrollController) {
|
||||||
|
_scrollController?.dispose();
|
||||||
|
}
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
_listStore.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,6 +112,8 @@ class _StreamsListState extends State<StreamsList>
|
||||||
? kToolbarHeight
|
? kToolbarHeight
|
||||||
: widget.listType == ListType.category
|
: widget.listType == ListType.category
|
||||||
? kToolbarHeight + 122
|
? kToolbarHeight + 122
|
||||||
|
: widget.listType == ListType.tag
|
||||||
|
? kToolbarHeight
|
||||||
: 0.0;
|
: 0.0;
|
||||||
|
|
||||||
final topPadding = MediaQuery.of(context).padding.top + extraTopPadding;
|
final topPadding = MediaQuery.of(context).padding.top + extraTopPadding;
|
||||||
|
|
@ -209,9 +228,12 @@ class _StreamsListState extends State<StreamsList>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
statusWidget = AlertMessage(
|
statusWidget = AlertMessage(
|
||||||
message: widget.listType == ListType.followed
|
message: switch (widget.listType) {
|
||||||
? 'No followed streams'
|
ListType.followed => 'No followed streams',
|
||||||
: 'No top streams',
|
ListType.tag => 'No streams with this tag',
|
||||||
|
ListType.category => 'No streams in this category',
|
||||||
|
ListType.top => 'No top streams',
|
||||||
|
},
|
||||||
vertical: true,
|
vertical: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -238,6 +260,12 @@ class _StreamsListState extends State<StreamsList>
|
||||||
: _listStore.streams;
|
: _listStore.streams;
|
||||||
|
|
||||||
final isFollowingTab = widget.listType == ListType.followed;
|
final isFollowingTab = widget.listType == ListType.followed;
|
||||||
|
// Refresh following lists after unfollow so live/offline rows drop.
|
||||||
|
void onFollowChanged(bool following) {
|
||||||
|
if (isFollowingTab && !following) {
|
||||||
|
_listStore.refreshStreams();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
alignment: AlignmentDirectional.bottomCenter,
|
alignment: AlignmentDirectional.bottomCenter,
|
||||||
|
|
@ -298,6 +326,9 @@ class _StreamsListState extends State<StreamsList>
|
||||||
widget.categoryId == null,
|
widget.categoryId == null,
|
||||||
showPinOption: true,
|
showPinOption: true,
|
||||||
isPinned: true,
|
isPinned: true,
|
||||||
|
initialIsFollowing: true,
|
||||||
|
onFollowChanged:
|
||||||
|
onFollowChanged,
|
||||||
)
|
)
|
||||||
: StreamCard(
|
: StreamCard(
|
||||||
key: ValueKey(stream.userId),
|
key: ValueKey(stream.userId),
|
||||||
|
|
@ -309,6 +340,9 @@ class _StreamsListState extends State<StreamsList>
|
||||||
widget.categoryId == null,
|
widget.categoryId == null,
|
||||||
showPinOption: true,
|
showPinOption: true,
|
||||||
isPinned: true,
|
isPinned: true,
|
||||||
|
initialIsFollowing: true,
|
||||||
|
onFollowChanged:
|
||||||
|
onFollowChanged,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -351,6 +385,9 @@ class _StreamsListState extends State<StreamsList>
|
||||||
showCategory: widget.categoryId == null,
|
showCategory: widget.categoryId == null,
|
||||||
showPinOption: isFollowingTab,
|
showPinOption: isFollowingTab,
|
||||||
isPinned: false,
|
isPinned: false,
|
||||||
|
initialIsFollowing:
|
||||||
|
isFollowingTab ? true : null,
|
||||||
|
onFollowChanged: onFollowChanged,
|
||||||
)
|
)
|
||||||
: StreamCard(
|
: StreamCard(
|
||||||
key: ValueKey(
|
key: ValueKey(
|
||||||
|
|
@ -362,6 +399,9 @@ class _StreamsListState extends State<StreamsList>
|
||||||
showCategory: widget.categoryId == null,
|
showCategory: widget.categoryId == null,
|
||||||
showPinOption: isFollowingTab,
|
showPinOption: isFollowingTab,
|
||||||
isPinned: false,
|
isPinned: false,
|
||||||
|
initialIsFollowing:
|
||||||
|
isFollowingTab ? true : null,
|
||||||
|
onFollowChanged: onFollowChanged,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -410,6 +450,8 @@ class _StreamsListState extends State<StreamsList>
|
||||||
_listStore.offlineChannels[index],
|
_listStore.offlineChannels[index],
|
||||||
showPinOption: false,
|
showPinOption: false,
|
||||||
isPinned: false,
|
isPinned: false,
|
||||||
|
initialIsFollowing: true,
|
||||||
|
onFollowChanged: onFollowChanged,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
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