Compare commits
42 commits
tc/chat-pe
...
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 |
107 changed files with 7555 additions and 788 deletions
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"
|
||||||
43
.github/workflows/claude-code-review.yml
vendored
43
.github/workflows/claude-code-review.yml
vendored
|
|
@ -1,43 +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@v6
|
|
||||||
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
|
|
||||||
49
.github/workflows/claude.yml
vendored
49
.github/workflows/claude.yml
vendored
|
|
@ -1,49 +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@v6
|
|
||||||
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:*)'
|
|
||||||
18
.github/workflows/release-dryrun.yml
vendored
18
.github/workflows/release-dryrun.yml
vendored
|
|
@ -7,10 +7,10 @@ name: Release dry-run
|
||||||
on:
|
on:
|
||||||
# Run on demand before cutting a release.
|
# Run on demand before cutting a release.
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
# Weekly heartbeat so cert expiry, secret rotation, and dependency drift
|
# Monthly heartbeat so cert expiry, secret rotation, and dependency drift
|
||||||
# surface on their own between releases.
|
# surface on their own between releases.
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 9 * * 1' # Mondays 09:00 UTC
|
- cron: '0 9 1 * *' # 1st of the month, 09:00 UTC
|
||||||
# Setup environment variables for use in all jobs.
|
# Setup environment variables for use in all jobs.
|
||||||
env:
|
env:
|
||||||
# Gemfile path used for caching dependencies.
|
# Gemfile path used for caching dependencies.
|
||||||
|
|
@ -39,7 +39,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.
|
||||||
|
|
@ -69,8 +69,16 @@ jobs:
|
||||||
echo storeFile=$GITHUB_WORKSPACE/android/upload-keystore.jks >> $KEY_FILE_PATH
|
echo storeFile=$GITHUB_WORKSPACE/android/upload-keystore.jks >> $KEY_FILE_PATH
|
||||||
|
|
||||||
# Build the Android app bundle (AAB) and pass the environment variables.
|
# Build the Android app bundle (AAB) and pass the environment variables.
|
||||||
|
#
|
||||||
|
# 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
|
- name: Build the Android AAB
|
||||||
run: flutter build appbundle --dart-define CLIENT_ID=$CLIENT_ID --dart-define SECRET=$SECRET
|
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.
|
# Validate the AAB and credentials against Google Play without publishing.
|
||||||
- name: Validate release with fastlane (no publish)
|
- name: Validate release with fastlane (no publish)
|
||||||
|
|
@ -98,7 +106,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 and cache Flutter to install packages and build.
|
# Setup and cache Flutter to install packages and build.
|
||||||
|
|
|
||||||
4
.github/workflows/store-release.yml
vendored
4
.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.
|
||||||
|
|
@ -94,7 +94,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 and cache Flutter to install packages and build.
|
# Setup and cache Flutter to install packages and build.
|
||||||
|
|
|
||||||
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"]
|
||||||
|
|
|
||||||
60
AGENTS.md
60
AGENTS.md
|
|
@ -1,48 +1,46 @@
|
||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
Flutter mobile app for browsing Twitch on iOS and Android. Uses MobX for state management with code generation.
|
Flutter Twitch client for iOS and Android. MobX with `build_runner`. The landing page is `web/` (own `AGENTS.md`). Flutter is pinned in `pubspec.yaml` `environment.flutter` — bump that one field; CI reads it via `flutter-version-file`.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
flutter pub get # Install dependencies
|
flutter pub get
|
||||||
flutter run --dart-define=CLIENT_ID=ID --dart-define=SECRET=SECRET # Run with Twitch credentials
|
flutter run --dart-define-from-file=.env # local; .env is gitignored
|
||||||
flutter analyze # Static analysis
|
flutter run --dart-define=CLIENT_ID=ID --dart-define=SECRET=SECRET
|
||||||
flutter test # Run tests
|
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
|
- `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
|
|
||||||
|
|
||||||
## Code Opinions
|
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`.
|
||||||
|
|
||||||
- Extract related/grouped logic (state, reactions, actions) into dedicated stores when it improves readability — keep widgets focused on rendering
|
|
||||||
- Default to writing tests: new or changed logic (stores, API clients, parsing, utils) ships with tests; when touching existing tested code, update or extend its tests. Pure UI/rendering or config changes may skip.
|
|
||||||
|
|
||||||
## 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.
|
||||||
- Several deps in `pubspec.yaml` are git-pinned forks carrying compatibility patches (`extended_text_field`, `better_native_video_player`, `simple_pip_mode`). When upgrading dependencies, don't swap them back to pub.dev releases.
|
- `flutter_secure_storage` stays on v9 (v10 shipped in 5.0.0 and was reverted in 5.0.1 over an Android 14 startup freeze). That also holds `package_info_plus` v9 and `device_info_plus` v12 (win32). Don't jump them in a drive-by upgrade.
|
||||||
|
- Git-pinned forks in `pubspec.yaml` (`extended_text_field`, `better_native_video_player`, `simple_pip_mode`) carry patches. Don't swap them back to pub.dev.
|
||||||
|
- `android/gradle.properties`: keep `android.builtInKotlin=false` and `android.newDsl=false` so plugins that still apply KGP build on AGP 9.
|
||||||
|
- `ios/Runner/AppDelegate.swift` is customized (`CookieExtractorPlugin` + `FlutterImplicitEngineDelegate`). Flutter will not auto-migrate UIScene; don't replace the delegate with the template.
|
||||||
|
- `android/.../MainActivity.kt` extends `PipCallbackHelperActivityWrapper` and registers `CookieExtractorPlugin` by hand. Don't revert it to `FlutterActivity`.
|
||||||
|
- Helix OAuth (`Bearer`) and the Twitch web GQL cookie are different tokens. Native playback uses the cookie; don't send the Helix token to GQL/Usher.
|
||||||
|
- `syncedChatDelay` is runtime-only (`includeFromJson/toJson: false`). Don't persist it with the other settings.
|
||||||
|
- Don't remove the `ALLOW_DEBUG_SIGNED_RELEASE` branch in `android/app/build.gradle.kts`. Real releases must still fail if `key.properties` is missing.
|
||||||
|
|
||||||
## 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)
|
|
||||||
- Test layout mirrors `lib/` (`test/apis/`, `test/models/`, `test/screens/`); shared fixtures live in `test/fixtures/` (e.g., `irc_messages.dart`, `api_responses.dart`)
|
|
||||||
|
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -64,6 +59,12 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ 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 |options|
|
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
|
# With dry_run:true (release dry-run workflow), validate_only authenticates and
|
||||||
# validates the AAB against Google Play without committing the release.
|
# validates the AAB against Google Play without committing the release.
|
||||||
upload_to_play_store(
|
upload_to_play_store(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,145 +1,28 @@
|
||||||
## v5.1.0
|
## v5.3.1
|
||||||
|
|
||||||
June 13, 2026
|
October 12, 2026
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v5.3.0
|
||||||
|
|
||||||
|
October 12, 2026
|
||||||
|
|
||||||
### New Features
|
### New Features
|
||||||
|
|
||||||
- Added a new experimental native video player option with quality selection, audio-only playback, Picture-in-Picture support, and improved latency recovery.
|
- Added a Follow / Unfollow button under the player (between video and chat) for live and offline channels.
|
||||||
- Added merged chat mode for multi-tab chat, so messages from active tabs can be viewed together.
|
|
||||||
- Added new chat settings for keeping the screen on and showing timestamps on historical messages.
|
|
||||||
- Added support for linking your Twitch web session so the native player can use authenticated playback on eligible channels.
|
|
||||||
|
|
||||||
### Improvements
|
|
||||||
|
|
||||||
- Improved chat send reliability, especially when chat delay is enabled.
|
|
||||||
- Improved chat delay sync so control messages, acknowledgements, and reconnect notices are not delayed with chat messages.
|
|
||||||
- Improved video stability around refreshes, stalls, high latency, Picture-in-Picture, fullscreen, and ad breaks.
|
|
||||||
- Redesigned chat tabs with unread indicators, connection state styling, and disconnect options.
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Fixed recent messages failing on valueless or escaped IRC tags.
|
|
||||||
- Fixed shared chat badges and emotes lingering after leaving shared chat.
|
|
||||||
- Fixed recent searches not saving from type-to-search.
|
|
||||||
- Fixed auto-synced chat delay not applying when toggled on mid-session.
|
|
||||||
- Fixed long chat inputs overlapping the message list.
|
|
||||||
- Fixed a video refresh race that could leave a black screen with only the play button visible.
|
|
||||||
- Fixed fullscreen button and system UI state getting out of sync.
|
|
||||||
- Fixed landscape safe area and iPad gesture padding issues.
|
|
||||||
- Fixed Twitch/API auth requests that could hang instead of failing cleanly.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v5.0.5
|
## v5.2.9
|
||||||
|
|
||||||
January 9, 2026
|
October 7, 2026
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- Fixed lag/frame drops in overlay chat mode.
|
- Fixed tag stream lists reshuffling when loading more pages by paginating with Twitch’s sorted tag directory (GQL) instead of scanning Helix.
|
||||||
- Fixed draggable divider still interactable in fullscreen mode.
|
- Fixed follow/unfollow failing with an integrity check error while logged in by confirming via a short Twitch WebView when needed.
|
||||||
- Fixed landscape bottom padding in chat bottom bar.
|
|
||||||
- [iOS] Fixed user agent breaking login.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v5.0.4
|
|
||||||
|
|
||||||
January 8, 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
|
|
||||||
|
|
||||||
- Open multiple chat tabs for different channels with drag-to-reorder. Tabs are saved between sessions by default (configurable in settings).
|
|
||||||
- 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.
|
|
||||||
- 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.
|
|
||||||
- Improved formatting for chat replies, quick copy-to-input for messages, and a clearer countdown for chat delays.
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Fixed chat text input not unfocusing when tapping outside of it (by @zeykafx on GitHub).
|
|
||||||
- Fixed missing badges and emotes in historical messages upon initial connection.
|
|
||||||
- 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>
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,9 @@ EXTERNAL SOURCES:
|
||||||
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
advanced_in_app_review: fe8fd50b8670cb3dbe67e4c26ca643f7aea8dbb8
|
advanced_in_app_review: daebe4c801525b756fe589de60fd327cb12f965c
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
|
||||||
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
|
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
||||||
|
|
||||||
PODFILE CHECKSUM: 57c8aed26fba39d3ec9424816221f294a07c58eb
|
PODFILE CHECKSUM: 57c8aed26fba39d3ec9424816221f294a07c58eb
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "8d5b4189f1f482df8d5c58c9985ea70491ef5382",
|
"revision" : "33a468adfdb75b53f05a37e7c886ca7c962b5c17",
|
||||||
"version" : "12.14.0"
|
"version" : "12.17.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -41,8 +41,8 @@
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "219e564a8510e983e675c94f77f7f7c50049f22d",
|
"revision" : "fceaffa07d22dcd5624d3639fd970351a4a5ad8c",
|
||||||
"version" : "12.14.0"
|
"version" : "12.17.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "8d5b4189f1f482df8d5c58c9985ea70491ef5382",
|
"revision" : "33a468adfdb75b53f05a37e7c886ca7c962b5c17",
|
||||||
"version" : "12.14.0"
|
"version" : "12.17.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -41,8 +41,8 @@
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "219e564a8510e983e675c94f77f7f7c50049f22d",
|
"revision" : "fceaffa07d22dcd5624d3639fd970351a4a5ad8c",
|
||||||
"version" : "12.14.0"
|
"version" : "12.17.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -2,19 +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)
|
||||||
guard let cookieExtractorRegistrar = self.registrar(
|
}
|
||||||
|
|
||||||
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||||
|
guard let cookieExtractorRegistrar = engineBridge.pluginRegistry.registrar(
|
||||||
forPlugin: "CookieExtractorPlugin"
|
forPlugin: "CookieExtractorPlugin"
|
||||||
) else {
|
) else {
|
||||||
assertionFailure("Failed to create CookieExtractorPlugin registrar")
|
assertionFailure("Failed to create CookieExtractorPlugin registrar")
|
||||||
return false
|
return
|
||||||
}
|
}
|
||||||
CookieExtractorPlugin.register(with: cookieExtractorRegistrar)
|
CookieExtractorPlugin.register(with: cookieExtractorRegistrar)
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,27 @@
|
||||||
<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>
|
<key>UIBackgroundModes</key>
|
||||||
|
|
|
||||||
|
|
@ -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>(
|
||||||
|
|
@ -501,8 +659,8 @@ class TwitchApi extends BaseApiClient {
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
// Existing users may not have re-authenticated with the new scope yet.
|
// Backstop: callers should skip this without moderator scopes, but a
|
||||||
// Treat this as non-fatal and hide moderation actions.
|
// stale/partial token must not take down chat.
|
||||||
debugPrint('Failed to get moderated channels: $e');
|
debugPrint('Failed to get moderated channels: $e');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
@ -583,3 +741,13 @@ class TwitchApi extends BaseApiClient {
|
||||||
return true;
|
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});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,78 @@ import 'dart:math';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:frosty/apis/base_api_client.dart';
|
import 'package:frosty/apis/base_api_client.dart';
|
||||||
|
import 'package:frosty/apis/twitch_integrity.dart';
|
||||||
import 'package:frosty/constants.dart';
|
import 'package:frosty/constants.dart';
|
||||||
import 'package:frosty/models/playback_access_token.dart';
|
import 'package:frosty/models/playback_access_token.dart';
|
||||||
|
import 'package:frosty/models/stream.dart';
|
||||||
|
|
||||||
/// Twitch GQL API client for fetching playback access tokens.
|
/// 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)
|
/// Uses the Twitch web client ID (not the app's registered client ID)
|
||||||
/// since the GQL endpoint requires it.
|
/// since the GQL endpoint requires it.
|
||||||
class TwitchGqlApi extends BaseApiClient {
|
class TwitchGqlApi extends BaseApiClient {
|
||||||
TwitchGqlApi(Dio dio) : super(dio, 'https://gql.twitch.tv');
|
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
|
/// Browser-consistent User-Agent for playback-related requests. Twitch has
|
||||||
/// rejected access-token requests with non-browser UAs before
|
/// rejected access-token requests with non-browser UAs before
|
||||||
/// (streamlink#6574); sending one consistent with the web client ID and
|
/// (streamlink#6574); sending one consistent with the web client ID and
|
||||||
|
|
@ -34,12 +96,18 @@ class TwitchGqlApi extends BaseApiClient {
|
||||||
/// When [authToken] is provided, the request is authenticated which may
|
/// When [authToken] is provided, the request is authenticated which may
|
||||||
/// grant ad-free playback for subscribers and Turbo users.
|
/// 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
|
/// Uses the web client's persisted GQL query (smaller payload, blends in
|
||||||
/// with regular web traffic); falls back to the inline query template if
|
/// with regular web traffic); falls back to the inline query template if
|
||||||
/// Twitch ever rotates the persisted-query hash.
|
/// Twitch ever rotates the persisted-query hash.
|
||||||
Future<PlaybackAccessToken> getPlaybackAccessToken({
|
Future<PlaybackAccessToken> getPlaybackAccessToken({
|
||||||
required String login,
|
required String login,
|
||||||
String? authToken,
|
String? authToken,
|
||||||
|
String playerType = 'site',
|
||||||
|
String platform = 'web',
|
||||||
}) async {
|
}) async {
|
||||||
final headers = <String, String>{
|
final headers = <String, String>{
|
||||||
'Client-ID': twitchGqlClientId,
|
'Client-ID': twitchGqlClientId,
|
||||||
|
|
@ -52,8 +120,8 @@ class TwitchGqlApi extends BaseApiClient {
|
||||||
'login': login,
|
'login': login,
|
||||||
'isVod': false,
|
'isVod': false,
|
||||||
'vodID': '',
|
'vodID': '',
|
||||||
'playerType': 'site',
|
'playerType': playerType,
|
||||||
'platform': 'web',
|
'platform': platform,
|
||||||
};
|
};
|
||||||
|
|
||||||
Future<JsonMap> query(Map<String, Object> body) =>
|
Future<JsonMap> query(Map<String, Object> body) =>
|
||||||
|
|
@ -92,6 +160,11 @@ class TwitchGqlApi extends BaseApiClient {
|
||||||
return PlaybackAccessToken.fromGqlResponse(response);
|
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].
|
/// Builds the HLS stream URL for the given channel [login] and [token].
|
||||||
String buildHlsUrl({
|
String buildHlsUrl({
|
||||||
required String login,
|
required String login,
|
||||||
|
|
@ -129,4 +202,358 @@ class TwitchGqlApi extends BaseApiClient {
|
||||||
}
|
}
|
||||||
return buffer.toString();
|
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';
|
||||||
|
|
||||||
|
|
@ -23,8 +24,57 @@ class UnauthorizedInterceptor extends Interceptor {
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
@ -33,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,
|
||||||
|
|
@ -60,31 +105,22 @@ 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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -97,3 +133,11 @@ class UnauthorizedInterceptor extends Interceptor {
|
||||||
url.startsWith('https://id.twitch.tv/oauth2');
|
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');
|
||||||
|
|
|
||||||
|
|
@ -167,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));
|
||||||
|
|
|
||||||
|
|
@ -1025,7 +1025,7 @@ class IRCMessage {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 prefix = splitMessage[0];
|
final prefix = splitMessage[0];
|
||||||
final String? user;
|
final String? user;
|
||||||
if (prefix == _twitchIrcServer) {
|
if (prefix == _twitchIrcServer) {
|
||||||
|
|
@ -1069,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(
|
||||||
|
|
@ -1304,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',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,8 +6,8 @@ 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.dart';
|
||||||
import 'package:frosty/screens/channel/video/native_video_player_interface.dart';
|
|
||||||
import 'package:frosty/screens/channel/video/native_video_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.dart';
|
import 'package:frosty/screens/channel/video/video.dart';
|
||||||
|
|
@ -747,6 +747,14 @@ class _VideoChatState extends State<VideoChat>
|
||||||
aspectRatio: 16 / 9,
|
aspectRatio: 16 / 9,
|
||||||
child: SizedBox(),
|
child: SizedBox(),
|
||||||
),
|
),
|
||||||
|
ChannelInfoHeader(
|
||||||
|
videoStore: _videoStore,
|
||||||
|
authStore: context.authStore,
|
||||||
|
userId: widget.userId,
|
||||||
|
displayName: widget.userName,
|
||||||
|
isInSharedChatMode:
|
||||||
|
_chatStore.isInSharedChatMode,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
Expanded(child: chat),
|
Expanded(child: chat),
|
||||||
],
|
],
|
||||||
|
|
@ -772,12 +780,10 @@ 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) {
|
||||||
void notifyPip(bool isInPip) {
|
// Both players need this — the WebView store used to be skipped by a
|
||||||
final store = _videoStore;
|
// NativeVideoPlayerInterface type check, so it never learned it was in
|
||||||
if (store is NativeVideoPlayerInterface) {
|
// PiP on Android.
|
||||||
store.handleAndroidPipChanged(isInPip);
|
void notifyPip(bool isInPip) => _videoStore.handleAndroidPipChanged(isInPip);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return PipWidget(
|
return PipWidget(
|
||||||
pipLayout: PipActionsLayout.mediaOnlyPause,
|
pipLayout: PipActionsLayout.mediaOnlyPause,
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -30,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.
|
||||||
|
|
@ -271,7 +271,7 @@ 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.
|
||||||
final base = (!_autoScroll || _messages.length < _renderMessageLimit)
|
final base = (!_autoScroll || _messages.length < _renderMessageLimit)
|
||||||
? _messages
|
? _messages
|
||||||
: _messages.sublist(_messages.length - _renderMessageLimit);
|
: _messages.sublist(_messages.length - _renderMessageLimit);
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,18 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||||
import 'package:frosty/apis/base_api_client.dart';
|
import 'package:frosty/apis/base_api_client.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/onboarding/login_webview.dart';
|
||||||
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
|
|
||||||
/// Moderator actions (delete / timeout / ban, plus unban) for [targetUserId],
|
/// Moderator actions (delete / timeout / ban, plus unban) for [targetUserId],
|
||||||
/// reused by the message long-press menu and the user panel.
|
/// reused by the message long-press menu and the user panel.
|
||||||
///
|
///
|
||||||
/// Renders nothing unless the viewer can moderate the channel. [messageId]
|
/// Renders nothing unless the viewer can moderate the channel, or should see
|
||||||
/// enables the per-message Delete action; omit it (e.g. the user panel, which
|
/// an opt-in row when IRC/broadcaster hints mod capability without OAuth scopes.
|
||||||
/// isn't tied to one message) to hide Delete. The unban action only appears
|
/// [messageId] enables the per-message Delete action; omit it (e.g. the user
|
||||||
/// when the user is currently banned/timed-out (seen via CLEARCHAT).
|
/// 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 {
|
class ModerationActions extends StatelessWidget {
|
||||||
final ChatStore chatStore;
|
final ChatStore chatStore;
|
||||||
final String targetUserId;
|
final String targetUserId;
|
||||||
|
|
@ -29,10 +33,54 @@ class ModerationActions extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (!chatStore.auth.user.canModerate(chatStore.channelId)) {
|
return Observer(
|
||||||
return const SizedBox.shrink();
|
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(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -133,7 +181,9 @@ class ModerationActions extends StatelessWidget {
|
||||||
duration: 600, // 10 minutes
|
duration: 600, // 10 minutes
|
||||||
);
|
);
|
||||||
chatStore.updateNotification(
|
chatStore.updateNotification(
|
||||||
success ? '$targetName timed out for 10 minutes.' : 'Failed to timeout user',
|
success
|
||||||
|
? '$targetName timed out for 10 minutes.'
|
||||||
|
: 'Failed to timeout user',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,8 +5,4 @@ import 'package:frosty/screens/channel/video/video_player_interface.dart';
|
||||||
abstract class NativeVideoPlayerInterface extends VideoPlayerInterface {
|
abstract class NativeVideoPlayerInterface extends VideoPlayerInterface {
|
||||||
/// Whether the current quality is an HLS audio-only variant.
|
/// Whether the current quality is an HLS audio-only variant.
|
||||||
bool get isAudioOnlyMode;
|
bool get isAudioOnlyMode;
|
||||||
|
|
||||||
/// Called from platform PiP callbacks on Android. Lets the store sync
|
|
||||||
/// its overlay state with the OS PiP window.
|
|
||||||
void handleAndroidPipChanged(bool isInPip);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:frosty/apis/base_api_client.dart';
|
import 'package:frosty/apis/base_api_client.dart';
|
||||||
|
import 'package:frosty/apis/twitch_ad_solutions.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/apis/twitch_gql_api.dart';
|
||||||
import 'package:frosty/constants.dart';
|
import 'package:frosty/constants.dart';
|
||||||
|
|
@ -14,6 +15,7 @@ import 'package:frosty/models/playback_access_token.dart';
|
||||||
import 'package:frosty/models/stream.dart';
|
import 'package:frosty/models/stream.dart';
|
||||||
import 'package:frosty/screens/channel/video/chat_latency_sync.dart';
|
import 'package:frosty/screens/channel/video/chat_latency_sync.dart';
|
||||||
import 'package:frosty/screens/channel/video/native_video_player_interface.dart';
|
import 'package:frosty/screens/channel/video/native_video_player_interface.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/playback_error_recovery.dart';
|
||||||
import 'package:frosty/screens/channel/video/stream_info_poller.dart';
|
import 'package:frosty/screens/channel/video/stream_info_poller.dart';
|
||||||
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
|
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
|
||||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
|
|
@ -30,6 +32,8 @@ abstract class NativeVideoStoreBase
|
||||||
with Store
|
with Store
|
||||||
implements NativeVideoPlayerInterface {
|
implements NativeVideoPlayerInterface {
|
||||||
static int _nextId = 0;
|
static int _nextId = 0;
|
||||||
|
static const _legacyWebViewInSettings =
|
||||||
|
'the legacy WebView player in Settings';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String userLogin;
|
final String userLogin;
|
||||||
|
|
@ -111,9 +115,29 @@ abstract class NativeVideoStoreBase
|
||||||
/// suspended rather than burning refresh attempts on ad-induced noise.
|
/// suspended rather than burning refresh attempts on ad-induced noise.
|
||||||
var _isAdBreakActive = false;
|
var _isAdBreakActive = false;
|
||||||
|
|
||||||
|
/// True after we swapped to a TwitchAdSolutions backup player-type URL.
|
||||||
|
var _usingAdBackup = false;
|
||||||
|
|
||||||
|
/// Serializes ad-break URL swaps so overlapping adBreakStream events don't
|
||||||
|
/// race loadUrl calls.
|
||||||
|
var _adSwapInFlight = false;
|
||||||
|
|
||||||
|
late final TwitchAdFreePlayback _adFreePlayback =
|
||||||
|
TwitchAdFreePlayback(twitchGqlApi);
|
||||||
|
|
||||||
@readonly
|
@readonly
|
||||||
var _loading = true;
|
var _loading = true;
|
||||||
|
|
||||||
|
/// Whether ad blocking is actively avoiding/replacing an ad pod.
|
||||||
|
@readonly
|
||||||
|
var _blockingAds = false;
|
||||||
|
|
||||||
|
/// Exposes [_blockingAds] via [VideoPlayerInterface.isBlockingAds]
|
||||||
|
/// (must live in the hand-written store — build_runner will strip a
|
||||||
|
/// hand-patched override from the `.g.dart`).
|
||||||
|
@override
|
||||||
|
bool get isBlockingAds => _blockingAds;
|
||||||
|
|
||||||
@readonly
|
@readonly
|
||||||
var _paused = true;
|
var _paused = true;
|
||||||
|
|
||||||
|
|
@ -274,6 +298,12 @@ abstract class NativeVideoStoreBase
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'NativeVideoStore: ad break ${isActive ? 'started' : 'ended'}',
|
'NativeVideoStore: ad break ${isActive ? 'started' : 'ended'}',
|
||||||
);
|
);
|
||||||
|
if (!settingsStore.blockTwitchAds) {
|
||||||
|
runInAction(() => _blockingAds = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fire-and-forget: swap stream URL per TwitchAdSolutions backup path.
|
||||||
|
unawaited(_onAdBreakChanged(isActive));
|
||||||
});
|
});
|
||||||
|
|
||||||
_pipSub = _controller!.isPipEnabledStream.listen((isPip) {
|
_pipSub = _controller!.isPipEnabledStream.listen((isPip) {
|
||||||
|
|
@ -376,12 +406,27 @@ abstract class NativeVideoStoreBase
|
||||||
Future<void> _loadStream(int generation) async {
|
Future<void> _loadStream(int generation) async {
|
||||||
// Use web cookie token (works with web Client-ID) for ad-free playback.
|
// Use web cookie token (works with web Client-ID) for ad-free playback.
|
||||||
final authToken = authStore.gqlToken;
|
final authToken = authStore.gqlToken;
|
||||||
|
late final String hlsUrl;
|
||||||
late final PlaybackAccessToken token;
|
late final PlaybackAccessToken token;
|
||||||
try {
|
try {
|
||||||
token = await twitchGqlApi.getPlaybackAccessToken(
|
if (settingsStore.blockTwitchAds) {
|
||||||
login: userLogin,
|
final resolved = await _adFreePlayback.resolvePrimary(
|
||||||
authToken: authToken,
|
login: userLogin,
|
||||||
);
|
authToken: authToken,
|
||||||
|
);
|
||||||
|
hlsUrl = resolved.url;
|
||||||
|
token = resolved.token;
|
||||||
|
debugPrint(
|
||||||
|
'NativeVideoStore: adblock primary '
|
||||||
|
'playerType=${resolved.playerType}',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
token = await twitchGqlApi.getPlaybackAccessToken(
|
||||||
|
login: userLogin,
|
||||||
|
authToken: authToken,
|
||||||
|
);
|
||||||
|
hlsUrl = twitchGqlApi.buildHlsUrl(login: userLogin, token: token);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (authToken != null) {
|
if (authToken != null) {
|
||||||
debugPrint('NativeVideoStore: auth token failed, retrying without: $e');
|
debugPrint('NativeVideoStore: auth token failed, retrying without: $e');
|
||||||
|
|
@ -390,7 +435,16 @@ abstract class NativeVideoStoreBase
|
||||||
// reflects the true state and future loads skip this request.
|
// reflects the true state and future loads skip this request.
|
||||||
authStore.invalidateGqlToken();
|
authStore.invalidateGqlToken();
|
||||||
}
|
}
|
||||||
token = await twitchGqlApi.getPlaybackAccessToken(login: userLogin);
|
if (settingsStore.blockTwitchAds) {
|
||||||
|
final resolved = await _adFreePlayback.resolvePrimary(
|
||||||
|
login: userLogin,
|
||||||
|
);
|
||||||
|
hlsUrl = resolved.url;
|
||||||
|
token = resolved.token;
|
||||||
|
} else {
|
||||||
|
token = await twitchGqlApi.getPlaybackAccessToken(login: userLogin);
|
||||||
|
hlsUrl = twitchGqlApi.buildHlsUrl(login: userLogin, token: token);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
|
|
@ -399,10 +453,11 @@ abstract class NativeVideoStoreBase
|
||||||
if (_disposed || generation != _initGeneration || !settingsStore.showVideo) {
|
if (_disposed || generation != _initGeneration || !settingsStore.showVideo) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
runInAction(
|
runInAction(() {
|
||||||
() => _restrictedStreamQualities = token.restrictedQualities,
|
_restrictedStreamQualities = token.restrictedQualities;
|
||||||
);
|
_usingAdBackup = false;
|
||||||
final hlsUrl = twitchGqlApi.buildHlsUrl(login: userLogin, token: token);
|
_blockingAds = false;
|
||||||
|
});
|
||||||
|
|
||||||
await _controller!.loadUrl(
|
await _controller!.loadUrl(
|
||||||
url: hlsUrl,
|
url: hlsUrl,
|
||||||
|
|
@ -415,6 +470,66 @@ abstract class NativeVideoStoreBase
|
||||||
_startLatencyPolling();
|
_startLatencyPolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TwitchAdSolutions backup swap when the native player reports a stitched
|
||||||
|
/// ad break; restores the primary (popout) URL when the break ends.
|
||||||
|
Future<void> _onAdBreakChanged(bool isActive) async {
|
||||||
|
if (_disposed || !settingsStore.blockTwitchAds || _controller == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_adSwapInFlight) return;
|
||||||
|
_adSwapInFlight = true;
|
||||||
|
final generation = _initGeneration;
|
||||||
|
try {
|
||||||
|
if (isActive) {
|
||||||
|
runInAction(() => _blockingAds = true);
|
||||||
|
final resolved = await _adFreePlayback.resolveBackup(
|
||||||
|
login: userLogin,
|
||||||
|
authToken: authStore.gqlToken,
|
||||||
|
);
|
||||||
|
if (_disposed || generation != _initGeneration) return;
|
||||||
|
debugPrint(
|
||||||
|
'NativeVideoStore: blocking ads via '
|
||||||
|
'playerType=${resolved.playerType}',
|
||||||
|
);
|
||||||
|
await _controller!.loadUrl(
|
||||||
|
url: resolved.url,
|
||||||
|
headers: TwitchGqlApi.playbackHttpHeaders,
|
||||||
|
);
|
||||||
|
if (_disposed || generation != _initGeneration) return;
|
||||||
|
await _controller!.configureForLivePlayback();
|
||||||
|
runInAction(() {
|
||||||
|
_usingAdBackup = true;
|
||||||
|
_restrictedStreamQualities = resolved.token.restrictedQualities;
|
||||||
|
});
|
||||||
|
} else if (_usingAdBackup) {
|
||||||
|
debugPrint('NativeVideoStore: ad break ended, restoring primary');
|
||||||
|
final resolved = await _adFreePlayback.resolvePrimary(
|
||||||
|
login: userLogin,
|
||||||
|
authToken: authStore.gqlToken,
|
||||||
|
);
|
||||||
|
if (_disposed || generation != _initGeneration) return;
|
||||||
|
await _controller!.loadUrl(
|
||||||
|
url: resolved.url,
|
||||||
|
headers: TwitchGqlApi.playbackHttpHeaders,
|
||||||
|
);
|
||||||
|
if (_disposed || generation != _initGeneration) return;
|
||||||
|
await _controller!.configureForLivePlayback();
|
||||||
|
runInAction(() {
|
||||||
|
_usingAdBackup = false;
|
||||||
|
_blockingAds = false;
|
||||||
|
_restrictedStreamQualities = resolved.token.restrictedQualities;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
runInAction(() => _blockingAds = false);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('NativeVideoStore: adblock swap failed: $e');
|
||||||
|
runInAction(() => _blockingAds = _isAdBreakActive || _usingAdBackup);
|
||||||
|
} finally {
|
||||||
|
_adSwapInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared error handler for both `_initPlayer` and `_reloadCurrentController`.
|
/// Shared error handler for both `_initPlayer` and `_reloadCurrentController`.
|
||||||
/// Checks stream liveness, auto-retries if CDN is still serving stale
|
/// Checks stream liveness, auto-retries if CDN is still serving stale
|
||||||
/// manifests, or surfaces the error with the overlay visible.
|
/// manifests, or surfaces the error with the overlay visible.
|
||||||
|
|
@ -465,7 +580,7 @@ abstract class NativeVideoStoreBase
|
||||||
// channel is expected to fail here and the overlay handles it.
|
// channel is expected to fail here and the overlay handles it.
|
||||||
if (_streamInfo != null) {
|
if (_streamInfo != null) {
|
||||||
_showErrorOverlay(
|
_showErrorOverlay(
|
||||||
'Native player failed to load. Try the standard player in Settings.',
|
'Native player failed to load. Try $_legacyWebViewInSettings.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -499,9 +614,19 @@ abstract class NativeVideoStoreBase
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keeps the Android PiP window's play/pause remote action in sync with
|
||||||
|
/// actual playback. Without this the button is stuck on whatever glyph the
|
||||||
|
/// `mediaOnlyPause` layout started with, and `onPipAction` — which toggles
|
||||||
|
/// blind — then does the opposite of what the button shows.
|
||||||
|
void _syncPipPlaybackState({required bool isPlaying}) {
|
||||||
|
if (!Platform.isAndroid) return;
|
||||||
|
_pip.setIsPlaying(isPlaying);
|
||||||
|
}
|
||||||
|
|
||||||
void _onPlaying() {
|
void _onPlaying() {
|
||||||
_loading = false;
|
_loading = false;
|
||||||
_paused = false;
|
_paused = false;
|
||||||
|
_syncPipPlaybackState(isPlaying: true);
|
||||||
_isStalled = false;
|
_isStalled = false;
|
||||||
_hasPlayedOnce = true;
|
_hasPlayedOnce = true;
|
||||||
_initializing = false;
|
_initializing = false;
|
||||||
|
|
@ -543,13 +668,14 @@ abstract class NativeVideoStoreBase
|
||||||
void _onPlayerError(Map<String, dynamic>? eventData) {
|
void _onPlayerError(Map<String, dynamic>? eventData) {
|
||||||
_loading = false;
|
_loading = false;
|
||||||
_paused = true;
|
_paused = true;
|
||||||
|
_syncPipPlaybackState(isPlaying: false);
|
||||||
_isStalled = false;
|
_isStalled = false;
|
||||||
_initializing = false;
|
_initializing = false;
|
||||||
_isQualitySwitching = false;
|
_isQualitySwitching = false;
|
||||||
_stallRecoveryTimer?.cancel();
|
_stallRecoveryTimer?.cancel();
|
||||||
final errorMessage =
|
final errorMessage =
|
||||||
eventData?['message'] as String? ??
|
eventData?['message'] as String? ??
|
||||||
'Playback error. Try refreshing or switch to the standard player in Settings.';
|
'Playback error. Try refreshing or switch to $_legacyWebViewInSettings.';
|
||||||
final errorCode = eventData?['code'] as String?;
|
final errorCode = eventData?['code'] as String?;
|
||||||
final statusCode = eventData?['statusCode'] as int?;
|
final statusCode = eventData?['statusCode'] as int?;
|
||||||
|
|
||||||
|
|
@ -558,26 +684,44 @@ abstract class NativeVideoStoreBase
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4xx on the HLS fetch means the current playback session is gone — a
|
// Mid-stream failures (expired HLS session, ExoPlayer "Source error"
|
||||||
// fresh GQL token + new query params can land on a different CDN edge
|
// after screen-off / network switch, CDN blips). A fresh token + reload
|
||||||
// with a working manifest. Skip the user-facing error and retry
|
// usually fixes these — only pin the Reload overlay once retries are
|
||||||
// immediately (handleRefresh updates _lastRefreshTime itself).
|
// spent. 4xx refreshes immediately; network/source waits briefly so the
|
||||||
if (statusCode != null &&
|
// interface can come back.
|
||||||
statusCode >= 400 &&
|
if (shouldAutoRecoverFromPlayerError(
|
||||||
statusCode < 500 &&
|
refreshCapReached: _refreshCapReached(),
|
||||||
!_refreshCapReached()) {
|
errorCode: errorCode,
|
||||||
|
)) {
|
||||||
if (_userPaused) {
|
if (_userPaused) {
|
||||||
// Don't yank a user-paused stream back to life — the refresh would
|
// Don't yank a user-paused stream back to life — the refresh would
|
||||||
// reload with autoplay. Defer until they resume.
|
// reload with autoplay. Defer until they resume.
|
||||||
_pendingRefreshOnResume = true;
|
_pendingRefreshOnResume = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final attempt = _totalRefreshAttempts + 1;
|
||||||
|
final delay = playerErrorRetryDelay(statusCode: statusCode);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'NativeVideoStore: $statusCode on HLS fetch, refreshing with fresh '
|
'NativeVideoStore: player error'
|
||||||
'token (attempt ${_totalRefreshAttempts + 1}/${VideoTimingConstants.maxRefreshAttempts})',
|
'${statusCode != null ? ' (HTTP $statusCode)' : ''}'
|
||||||
|
'${errorMessage.isNotEmpty ? ' — $errorMessage' : ''}, '
|
||||||
|
'auto-refresh in ${delay.inMilliseconds}ms '
|
||||||
|
'(attempt $attempt/${VideoTimingConstants.maxRefreshAttempts})',
|
||||||
);
|
);
|
||||||
_totalRefreshAttempts++;
|
_totalRefreshAttempts = attempt;
|
||||||
_handleRefresh(userInitiated: false);
|
_loading = true;
|
||||||
|
_error = null;
|
||||||
|
_initRetryTimer?.cancel();
|
||||||
|
final generation = _initGeneration;
|
||||||
|
if (delay == Duration.zero) {
|
||||||
|
_handleRefresh(userInitiated: false);
|
||||||
|
} else {
|
||||||
|
_initRetryTimer = Timer(delay, () {
|
||||||
|
if (_disposed || generation != _initGeneration) return;
|
||||||
|
// _handleRefresh parks behind _pendingRefreshOnResume when paused.
|
||||||
|
_handleRefresh(userInitiated: false);
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -667,6 +811,7 @@ abstract class NativeVideoStoreBase
|
||||||
void _onPaused() {
|
void _onPaused() {
|
||||||
_healthyPlaybackTimer?.cancel();
|
_healthyPlaybackTimer?.cancel();
|
||||||
_paused = true;
|
_paused = true;
|
||||||
|
_syncPipPlaybackState(isPlaying: false);
|
||||||
_isStalled = false;
|
_isStalled = false;
|
||||||
_isQualitySwitching = false;
|
_isQualitySwitching = false;
|
||||||
_stallRecoveryTimer?.cancel();
|
_stallRecoveryTimer?.cancel();
|
||||||
|
|
@ -739,8 +884,8 @@ abstract class NativeVideoStoreBase
|
||||||
_loading = false;
|
_loading = false;
|
||||||
_showErrorOverlay(
|
_showErrorOverlay(
|
||||||
bouncing
|
bouncing
|
||||||
? 'Twitch is having trouble with this channel right now. Try again in a moment, or switch to the standard player in Settings.'
|
? 'Twitch is having trouble with this channel right now. Try again in a moment, or switch to $_legacyWebViewInSettings.'
|
||||||
: 'Stream stalled. Try refreshing or switch to the standard player in Settings.',
|
: 'Stream stalled. Try refreshing or switch to $_legacyWebViewInSettings.',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1184,9 +1329,22 @@ abstract class NativeVideoStoreBase
|
||||||
if (_isInPipMode) return;
|
if (_isInPipMode) return;
|
||||||
_highLatencyCount = 0;
|
_highLatencyCount = 0;
|
||||||
updateStreamInfo(forceUpdate: true);
|
updateStreamInfo(forceUpdate: true);
|
||||||
if (!_userPaused && !_initializing && _controller != null) {
|
|
||||||
_controller!.play();
|
if (_userPaused || _initializing) return;
|
||||||
|
|
||||||
|
// After screen-off / a network drop the native player is often in an
|
||||||
|
// error state (ExoPlayer "Source error"). play() alone cannot revive a
|
||||||
|
// dead session — refresh like the Reload button would.
|
||||||
|
if (_error != null ||
|
||||||
|
_pendingRefreshOnResume ||
|
||||||
|
_controller == null ||
|
||||||
|
!_controllerInitialized) {
|
||||||
|
_pendingRefreshOnResume = false;
|
||||||
|
unawaited(_handleRefresh(userInitiated: false));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_controller!.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -1264,6 +1422,9 @@ abstract class NativeVideoStoreBase
|
||||||
_paused = true;
|
_paused = true;
|
||||||
_isStalled = false;
|
_isStalled = false;
|
||||||
_isAdBreakActive = false;
|
_isAdBreakActive = false;
|
||||||
|
_usingAdBackup = false;
|
||||||
|
_blockingAds = false;
|
||||||
|
_adSwapInFlight = false;
|
||||||
_restrictedStreamQualities = [];
|
_restrictedStreamQualities = [];
|
||||||
_isQualitySwitching = false;
|
_isQualitySwitching = false;
|
||||||
_isAudioOnlyMode = false;
|
_isAudioOnlyMode = false;
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,26 @@ mixin _$NativeVideoStore on NativeVideoStoreBase, Store {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
late final _$_pausedAtom = Atom(
|
||||||
name: 'NativeVideoStoreBase._paused',
|
name: 'NativeVideoStoreBase._paused',
|
||||||
context: context,
|
context: context,
|
||||||
|
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
@ -322,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: [
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ abstract class VideoPlayerInterface {
|
||||||
String get streamQuality;
|
String get streamQuality;
|
||||||
String? get latency;
|
String? get latency;
|
||||||
|
|
||||||
|
/// True while TwitchAdSolutions is actively avoiding an ad break.
|
||||||
|
bool get isBlockingAds;
|
||||||
|
|
||||||
void handleVideoTap();
|
void handleVideoTap();
|
||||||
void handlePausePlay();
|
void handlePausePlay();
|
||||||
void handleToggleOverlay();
|
void handleToggleOverlay();
|
||||||
|
|
@ -39,5 +42,10 @@ abstract class VideoPlayerInterface {
|
||||||
Future<void> setStreamQuality(String quality);
|
Future<void> setStreamQuality(String quality);
|
||||||
Future<void> updateStreamInfo({bool forceUpdate});
|
Future<void> updateStreamInfo({bool forceUpdate});
|
||||||
void handleAppResume();
|
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();
|
void dispose();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,10 +55,24 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface {
|
||||||
|
|
||||||
/// Whether [initVideo] should run on the next [onPageFinished].
|
/// Whether [initVideo] should run on the next [onPageFinished].
|
||||||
///
|
///
|
||||||
/// Dart-controlled instead of JS-side (`window._injected`) to avoid race
|
/// Dart-controlled rather than JS-side (`window._injected`) to avoid race
|
||||||
/// conditions when `loadRequest` fires before the previous page fully loads.
|
/// conditions when `loadRequest` fires before the previous page fully
|
||||||
|
/// loads — but re-armed by `onPageStarted` on *every* navigation so it
|
||||||
|
/// keeps the page-scoped lifetime the JS flag had for free. This controller
|
||||||
|
/// outlives the [Video] widget, which reloads [videoUrl] on each mount, and
|
||||||
|
/// a flag only [handleRefresh] reset left those reloads permanently
|
||||||
|
/// uninitialized: no play/pause listeners, no `_videoEl`, no autoplay, no
|
||||||
|
/// overlay CSS, and no recovery short of restarting the app.
|
||||||
var _needsInit = true;
|
var _needsInit = true;
|
||||||
|
|
||||||
|
/// Whether the *user* paused, as opposed to the page pausing itself.
|
||||||
|
///
|
||||||
|
/// Backgrounding can suspend playback with no way for the page to resume on
|
||||||
|
/// its own — the native store recovers via `play()` on resume, the webview
|
||||||
|
/// store had no equivalent. This distinguishes "resume it for them" from
|
||||||
|
/// "they meant it".
|
||||||
|
var _userPaused = false;
|
||||||
|
|
||||||
/// The video web view params used for enabling auto play.
|
/// The video web view params used for enabling auto play.
|
||||||
late final PlatformWebViewControllerCreationParams _videoWebViewParams;
|
late final PlatformWebViewControllerCreationParams _videoWebViewParams;
|
||||||
|
|
||||||
|
|
@ -158,30 +172,31 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface {
|
||||||
)
|
)
|
||||||
..addJavaScriptChannel(
|
..addJavaScriptChannel(
|
||||||
'PipEntered',
|
'PipEntered',
|
||||||
onMessageReceived: (message) {
|
onMessageReceived: (message) => _setPipActive(true),
|
||||||
_overlayWasVisibleBeforePip = _overlayVisible;
|
|
||||||
_isInPipMode = true;
|
|
||||||
_overlayTimer?.cancel();
|
|
||||||
_overlayVisible = true;
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
..addJavaScriptChannel(
|
..addJavaScriptChannel(
|
||||||
'PipExited',
|
'PipExited',
|
||||||
onMessageReceived: (message) {
|
onMessageReceived: (message) => _setPipActive(false),
|
||||||
_isInPipMode = false;
|
|
||||||
if (_overlayWasVisibleBeforePip) {
|
|
||||||
_updateLatencyTrackerVisibility(true);
|
|
||||||
_scheduleOverlayHide();
|
|
||||||
} else {
|
|
||||||
_overlayVisible = false;
|
|
||||||
_updateLatencyTrackerVisibility(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
..setNavigationDelegate(
|
..setNavigationDelegate(
|
||||||
NavigationDelegate(
|
NavigationDelegate(
|
||||||
|
// Every real navigation invalidates the injected JS context, so
|
||||||
|
// every one must re-arm init. Unconditional is safe: the
|
||||||
|
// onPageFinished handler below still filters on videoUrl.
|
||||||
|
onPageStarted: (url) {
|
||||||
|
_needsInit = true;
|
||||||
|
// Best-effort: inject vaft as early as the WebView allows.
|
||||||
|
// True document-start injection isn't exposed by webview_flutter
|
||||||
|
// on Android; native player remains the reliable adblock path.
|
||||||
|
if (settingsStore.blockTwitchAds) {
|
||||||
|
unawaited(_injectVaftUserScript());
|
||||||
|
}
|
||||||
|
},
|
||||||
onPageFinished: (url) async {
|
onPageFinished: (url) async {
|
||||||
if (url != videoUrl) return;
|
if (url != videoUrl) return;
|
||||||
|
if (settingsStore.blockTwitchAds) {
|
||||||
|
await _injectVaftUserScript();
|
||||||
|
}
|
||||||
if (!_needsInit) return;
|
if (!_needsInit) return;
|
||||||
_needsInit = false;
|
_needsInit = false;
|
||||||
await initVideo();
|
await initVideo();
|
||||||
|
|
@ -259,6 +274,11 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface {
|
||||||
@readonly
|
@readonly
|
||||||
var _isInPipMode = false;
|
var _isInPipMode = false;
|
||||||
|
|
||||||
|
/// WebView path relies on the injected vaft userscript's own overlay; the
|
||||||
|
/// Dart overlay banner is driven by the native player only.
|
||||||
|
@override
|
||||||
|
bool get isBlockingAds => false;
|
||||||
|
|
||||||
/// The video URL to use for the webview.
|
/// The video URL to use for the webview.
|
||||||
String get videoUrl =>
|
String get videoUrl =>
|
||||||
'https://player.twitch.tv/?channel=$userLogin&muted=false&parent=frosty';
|
'https://player.twitch.tv/?channel=$userLogin&muted=false&parent=frosty';
|
||||||
|
|
@ -632,6 +652,22 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Injects [assets/js/vaft.user.js] (TwitchAdSolutions) into the player page.
|
||||||
|
Future<void> _injectVaftUserScript() async {
|
||||||
|
try {
|
||||||
|
final raw = await rootBundle.loadString('assets/js/vaft.user.js');
|
||||||
|
// Strip UserScript metadata; keep the IIFE body.
|
||||||
|
final script = raw.replaceFirst(
|
||||||
|
RegExp(r'^[\s\S]*?==/UserScript==\s*', multiLine: true),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
await videoWebViewController.runJavaScript(script);
|
||||||
|
debugPrint('VideoStore: injected TwitchAdSolutions vaft');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('VideoStore: vaft inject failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Called whenever the video/overlay is tapped.
|
/// Called whenever the video/overlay is tapped.
|
||||||
@override
|
@override
|
||||||
@action
|
@action
|
||||||
|
|
@ -711,14 +747,71 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles app resume event for immediate stream info refresh in chat-only mode.
|
@override
|
||||||
|
@action
|
||||||
|
void handleAndroidPipChanged(bool isInPip) => _setPipActive(isInPip);
|
||||||
|
|
||||||
|
/// Shared PiP enter/exit state machine.
|
||||||
|
///
|
||||||
|
/// Driven by the Android activity callback and, on iOS, by the web PiP API
|
||||||
|
/// events posted from the page. Android previously reached neither — the
|
||||||
|
/// callback was routed only to the native store — so `_isInPipMode` stayed
|
||||||
|
/// false for the whole PiP session and the overlay kept auto-hiding.
|
||||||
|
@action
|
||||||
|
void _setPipActive(bool isInPip) {
|
||||||
|
if (isInPip == _isInPipMode) return;
|
||||||
|
|
||||||
|
if (isInPip) {
|
||||||
|
_overlayWasVisibleBeforePip = _overlayVisible;
|
||||||
|
_isInPipMode = true;
|
||||||
|
_overlayTimer?.cancel();
|
||||||
|
_overlayVisible = true;
|
||||||
|
} else {
|
||||||
|
_isInPipMode = false;
|
||||||
|
if (_overlayWasVisibleBeforePip) {
|
||||||
|
_updateLatencyTrackerVisibility(true);
|
||||||
|
_scheduleOverlayHide();
|
||||||
|
} else {
|
||||||
|
_overlayVisible = false;
|
||||||
|
_updateLatencyTrackerVisibility(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resumes playback when the page paused itself rather than the user doing
|
||||||
|
/// it. Safe to call spuriously — `play()` on an already-playing element is
|
||||||
|
/// a no-op.
|
||||||
|
///
|
||||||
|
/// Deliberately not called on PiP transitions: Twitch's embed refuses to
|
||||||
|
/// play below 400x300 CSS pixels, and an Android PiP window is far under
|
||||||
|
/// that, so a resume there is rejected no matter what we do.
|
||||||
|
void _resumeIfSuspended() {
|
||||||
|
if (!settingsStore.showVideo || !_paused || _userPaused) return;
|
||||||
|
try {
|
||||||
|
videoWebViewController.runJavaScript(
|
||||||
|
'(window._videoEl || document.getElementsByTagName("video")[0])?.play();',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the app returning to the foreground.
|
||||||
|
///
|
||||||
|
/// In chat-only mode this just refreshes the stream info. In video mode it
|
||||||
|
/// also resumes playback if the page paused itself while backgrounded —
|
||||||
|
/// the webview can be suspended when detached from the window and has no
|
||||||
|
/// way to restart on its own, which previously left the player dead until
|
||||||
|
/// a manual refresh. A pause the user asked for is left alone.
|
||||||
@override
|
@override
|
||||||
@action
|
@action
|
||||||
void handleAppResume() {
|
void handleAppResume() {
|
||||||
// Only refresh immediately in chat-only mode
|
|
||||||
if (!settingsStore.showVideo) {
|
if (!settingsStore.showVideo) {
|
||||||
updateStreamInfo(forceUpdate: true);
|
updateStreamInfo(forceUpdate: true);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_resumeIfSuspended();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates the stream info from the Twitch API.
|
/// Updates the stream info from the Twitch API.
|
||||||
|
|
@ -775,6 +868,7 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface {
|
||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
_loading = true;
|
_loading = true;
|
||||||
_paused = true;
|
_paused = true;
|
||||||
|
_userPaused = false;
|
||||||
_firstTimeSettingQuality = true;
|
_firstTimeSettingQuality = true;
|
||||||
_highLatencyCount = 0;
|
_highLatencyCount = 0;
|
||||||
_isInPipMode = false;
|
_isInPipMode = false;
|
||||||
|
|
@ -784,7 +878,8 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface {
|
||||||
_overlayTimer?.cancel();
|
_overlayTimer?.cancel();
|
||||||
_scheduleOverlayHide();
|
_scheduleOverlayHide();
|
||||||
|
|
||||||
// Signal that initVideo() should run on the next onPageFinished
|
// Belt-and-braces: onPageStarted re-arms this for the loadRequest below
|
||||||
|
// too, but don't depend on the delegate firing for an explicit refresh.
|
||||||
_needsInit = true;
|
_needsInit = true;
|
||||||
|
|
||||||
_stopLatencyTracker();
|
_stopLatencyTracker();
|
||||||
|
|
@ -797,6 +892,9 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface {
|
||||||
/// Play or pause the video depending on the current state of [_paused].
|
/// Play or pause the video depending on the current state of [_paused].
|
||||||
@override
|
@override
|
||||||
void handlePausePlay() {
|
void handlePausePlay() {
|
||||||
|
// Remember the user's intent so handleAppResume knows whether a pause is
|
||||||
|
// theirs or something the page did to itself.
|
||||||
|
_userPaused = !_paused;
|
||||||
try {
|
try {
|
||||||
if (_paused) {
|
if (_paused) {
|
||||||
videoWebViewController.runJavaScript(
|
videoWebViewController.runJavaScript(
|
||||||
|
|
|
||||||
|
|
@ -278,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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ class CategoryCard extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Calculate the dimmensions of the box art based on the current dimmensions of the screen.
|
// Calculate the dimensions of the box art based on the current dimensions of the screen.
|
||||||
final size = MediaQuery.of(context).size;
|
final size = MediaQuery.of(context).size;
|
||||||
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||||
final artWidth = (size.width * pixelRatio) ~/ 5;
|
final artWidth = (size.width * pixelRatio) ~/ 5;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
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/constants.dart';
|
import 'package:frosty/constants.dart';
|
||||||
import 'package:frosty/screens/home/stream_list/stream_list_store.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/home/stream_list/streams_list.dart';
|
||||||
|
|
@ -9,6 +10,7 @@ import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||||
import 'package:frosty/widgets/blurred_container.dart';
|
import 'package:frosty/widgets/blurred_container.dart';
|
||||||
import 'package:frosty/widgets/frosty_cached_network_image.dart';
|
import 'package:frosty/widgets/frosty_cached_network_image.dart';
|
||||||
import 'package:frosty/widgets/skeleton_loader.dart';
|
import 'package:frosty/widgets/skeleton_loader.dart';
|
||||||
|
import 'package:frosty/widgets/stream_sort_button.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
/// A widget that displays a list of streams under the provided [categoryId].
|
/// A widget that displays a list of streams under the provided [categoryId].
|
||||||
|
|
@ -25,12 +27,16 @@ class CategoryStreams extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CategoryStreamsState extends State<CategoryStreams> {
|
class _CategoryStreamsState extends State<CategoryStreams> {
|
||||||
|
late final _settingsStore = context.read<SettingsStore>();
|
||||||
|
|
||||||
late final _listStore = ListStore(
|
late final _listStore = ListStore(
|
||||||
listType: ListType.category,
|
listType: ListType.category,
|
||||||
categoryId: widget.categoryId,
|
categoryId: widget.categoryId,
|
||||||
authStore: context.read<AuthStore>(),
|
authStore: context.read<AuthStore>(),
|
||||||
twitchApi: context.read<TwitchApi>(),
|
twitchApi: context.read<TwitchApi>(),
|
||||||
settingsStore: context.read<SettingsStore>(),
|
twitchGqlApi: context.read<TwitchGqlApi>(),
|
||||||
|
settingsStore: _settingsStore,
|
||||||
|
sortOrder: _settingsStore.streamViewerSort,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -45,10 +51,13 @@ class _CategoryStreamsState extends State<CategoryStreams> {
|
||||||
children: [
|
children: [
|
||||||
// Stream list content behind the pinned elements
|
// Stream list content behind the pinned elements
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: StreamsList(
|
child: Observer(
|
||||||
listType: ListType.category,
|
builder: (_) => StreamsList(
|
||||||
categoryId: widget.categoryId,
|
listType: ListType.category,
|
||||||
showJumpButton: true,
|
categoryId: widget.categoryId,
|
||||||
|
sortOrder: _settingsStore.streamViewerSort,
|
||||||
|
showJumpButton: true,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Single blurred background spanning app bar and category card
|
// Single blurred background spanning app bar and category card
|
||||||
|
|
@ -64,75 +73,82 @@ class _CategoryStreamsState extends State<CategoryStreams> {
|
||||||
right: MediaQuery.of(context).padding.right,
|
right: MediaQuery.of(context).padding.right,
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// App bar section
|
// App bar section
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: kToolbarHeight,
|
height: kToolbarHeight,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Back',
|
tooltip: 'Back',
|
||||||
icon: Icon(Icons.adaptive.arrow_back_rounded),
|
icon: Icon(Icons.adaptive.arrow_back_rounded),
|
||||||
onPressed: Navigator.of(context).pop,
|
onPressed: Navigator.of(context).pop,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Observer(
|
||||||
|
builder: (_) => StreamSortButton(
|
||||||
|
sortOrder: _settingsStore.streamViewerSort,
|
||||||
|
onChanged: (order) =>
|
||||||
|
_settingsStore.streamViewerSort = order,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
// Category card section
|
),
|
||||||
Observer(
|
// Category card section
|
||||||
builder: (_) {
|
Observer(
|
||||||
if (_listStore.categoryDetails != null) {
|
builder: (_) {
|
||||||
return _TransparentCategoryCard(
|
if (_listStore.categoryDetails != null) {
|
||||||
category: _listStore.categoryDetails!,
|
return _TransparentCategoryCard(
|
||||||
);
|
category: _listStore.categoryDetails!,
|
||||||
} else {
|
);
|
||||||
// Skeleton loader for category card
|
} else {
|
||||||
return Padding(
|
// Skeleton loader for category card
|
||||||
padding: const EdgeInsets.only(
|
return Padding(
|
||||||
bottom: 16,
|
padding: const EdgeInsets.only(
|
||||||
left: 16,
|
bottom: 16,
|
||||||
right: 16,
|
left: 16,
|
||||||
),
|
right: 16,
|
||||||
child: Row(
|
),
|
||||||
spacing: 12,
|
child: Row(
|
||||||
children: [
|
spacing: 12,
|
||||||
const SizedBox(
|
children: [
|
||||||
width: 80,
|
const SizedBox(
|
||||||
child: AspectRatio(
|
width: 80,
|
||||||
aspectRatio: 3 / 4,
|
child: AspectRatio(
|
||||||
child: SkeletonLoader(
|
aspectRatio: 3 / 4,
|
||||||
borderRadius: kCardBorderRadius,
|
child: SkeletonLoader(
|
||||||
|
borderRadius: kCardBorderRadius,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
SkeletonLoader(
|
||||||
|
height: 20,
|
||||||
|
width: double.infinity,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
),
|
SkeletonLoader(
|
||||||
|
height: 16,
|
||||||
|
width: 120,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
Expanded(
|
),
|
||||||
child: Column(
|
],
|
||||||
crossAxisAlignment:
|
),
|
||||||
CrossAxisAlignment.start,
|
);
|
||||||
spacing: 8,
|
}
|
||||||
children: [
|
},
|
||||||
SkeletonLoader(
|
),
|
||||||
height: 20,
|
],
|
||||||
width: double.infinity,
|
),
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
SkeletonLoader(
|
|
||||||
height: 16,
|
|
||||||
width: 120,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||||
import 'package:frosty/screens/home/home_store.dart';
|
import 'package:frosty/screens/home/home_store.dart';
|
||||||
import 'package:frosty/screens/home/stream_list/stream_list_store.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/home/stream_list/streams_list.dart';
|
||||||
import 'package:frosty/screens/home/top/categories/categories.dart';
|
import 'package:frosty/screens/home/top/categories/categories.dart';
|
||||||
|
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||||
import 'package:frosty/widgets/blurred_container.dart';
|
import 'package:frosty/widgets/blurred_container.dart';
|
||||||
|
import 'package:frosty/widgets/stream_sort_button.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class TopSection extends StatefulWidget {
|
class TopSection extends StatefulWidget {
|
||||||
final HomeStore homeStore;
|
final HomeStore homeStore;
|
||||||
|
|
@ -21,23 +25,30 @@ class _TopSectionState extends State<TopSection>
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tabBarController.addListener(
|
_tabBarController.addListener(() {
|
||||||
() => widget.homeStore.topSectionCurrentIndex = _tabBarController.index,
|
widget.homeStore.topSectionCurrentIndex = _tabBarController.index;
|
||||||
);
|
setState(() {});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final settingsStore = context.watch<SettingsStore>();
|
||||||
|
final showSort = _tabBarController.index == 0;
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: TabBarView(
|
child: TabBarView(
|
||||||
controller: _tabBarController,
|
controller: _tabBarController,
|
||||||
children: [
|
children: [
|
||||||
StreamsList(
|
Observer(
|
||||||
listType: ListType.top,
|
builder: (_) => StreamsList(
|
||||||
scrollController:
|
listType: ListType.top,
|
||||||
widget.homeStore.topSectionScrollControllers[0],
|
sortOrder: settingsStore.streamViewerSort,
|
||||||
|
scrollController:
|
||||||
|
widget.homeStore.topSectionScrollControllers[0],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Categories(
|
Categories(
|
||||||
scrollController:
|
scrollController:
|
||||||
|
|
@ -57,12 +68,26 @@ class _TopSectionState extends State<TopSection>
|
||||||
left: MediaQuery.of(context).padding.left,
|
left: MediaQuery.of(context).padding.left,
|
||||||
right: MediaQuery.of(context).padding.right,
|
right: MediaQuery.of(context).padding.right,
|
||||||
),
|
),
|
||||||
child: TabBar(
|
child: Row(
|
||||||
isScrollable: true,
|
children: [
|
||||||
controller: _tabBarController,
|
Expanded(
|
||||||
tabs: const [
|
child: TabBar(
|
||||||
Tab(child: Text('Streams')),
|
isScrollable: true,
|
||||||
Tab(child: Text('Categories')),
|
controller: _tabBarController,
|
||||||
|
tabs: const [
|
||||||
|
Tab(child: Text('Streams')),
|
||||||
|
Tab(child: Text('Categories')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showSort)
|
||||||
|
Observer(
|
||||||
|
builder: (_) => StreamSortButton(
|
||||||
|
sortOrder: settingsStore.streamViewerSort,
|
||||||
|
onChanged: (order) =>
|
||||||
|
settingsStore.streamViewerSort = order,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,39 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:frosty/main.dart';
|
||||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
import 'package:frosty/widgets/frosty_app_bar.dart';
|
import 'package:frosty/widgets/frosty_app_bar.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';
|
||||||
import 'package:webview_flutter/webview_flutter.dart';
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
|
|
||||||
|
/// Pushes the atomic moderator-scopes upgrade WebView.
|
||||||
|
void openEnableModeratorTools(AuthStore authStore, {BuildContext? context}) {
|
||||||
|
final navContext = context ?? navigatorKey.currentContext;
|
||||||
|
if (navContext == null) return;
|
||||||
|
Navigator.of(navContext).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => Provider<AuthStore>.value(
|
||||||
|
value: authStore,
|
||||||
|
child: const LoginWebView(upgradeModeratorScopes: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Reusable WebView widget for Twitch OAuth login flow
|
/// Reusable WebView widget for Twitch OAuth login flow
|
||||||
class LoginWebView extends StatelessWidget {
|
class LoginWebView extends StatelessWidget {
|
||||||
/// Optional widget to navigate to after successful login
|
/// Optional widget to navigate to after successful login
|
||||||
final Widget? routeAfter;
|
final Widget? routeAfter;
|
||||||
|
|
||||||
const LoginWebView({super.key, this.routeAfter});
|
/// When true, requests core + moderator scopes and runs the atomic upgrade
|
||||||
|
/// commit instead of a full [AuthStore.login].
|
||||||
|
final bool upgradeModeratorScopes;
|
||||||
|
|
||||||
|
const LoginWebView({
|
||||||
|
super.key,
|
||||||
|
this.routeAfter,
|
||||||
|
this.upgradeModeratorScopes = false,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -18,7 +41,11 @@ class LoginWebView extends StatelessWidget {
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: FrostyAppBar(
|
appBar: FrostyAppBar(
|
||||||
title: const Text('Connect with Twitch'),
|
title: Text(
|
||||||
|
upgradeModeratorScopes
|
||||||
|
? 'Enable moderator tools'
|
||||||
|
: 'Connect with Twitch',
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.help_rounded),
|
icon: const Icon(Icons.help_rounded),
|
||||||
|
|
@ -28,7 +55,7 @@ class LoginWebView extends StatelessWidget {
|
||||||
return FrostyDialog(
|
return FrostyDialog(
|
||||||
title: 'Workaround for the Twitch cookie banner',
|
title: 'Workaround for the Twitch cookie banner',
|
||||||
message:
|
message:
|
||||||
'If the Twitch cookie banner is still blocking the login, try clicking one of the links in the cookie policy description and navigating until you reach the Twitch home page. From there, you can try logging in on the top right profile icon. Once logged in, go back to the first step of the onboarding and then try again.',
|
'If the Twitch cookie banner is still blocking the login, try clicking one of the links in the cookie policy description and navigating until you reach the Twitch home page. From there, try logging in from the top-right profile icon. Once logged in, go back to the first onboarding step and try again.',
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: Navigator.of(context).pop,
|
onPressed: Navigator.of(context).pop,
|
||||||
|
|
@ -44,6 +71,7 @@ class LoginWebView extends StatelessWidget {
|
||||||
body: WebViewWidget(
|
body: WebViewWidget(
|
||||||
controller: authStore.createAuthWebViewController(
|
controller: authStore.createAuthWebViewController(
|
||||||
routeAfter: routeAfter,
|
routeAfter: routeAfter,
|
||||||
|
upgradeModeratorScopes: upgradeModeratorScopes,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ class OnboardingLogin extends StatelessWidget {
|
||||||
return OnboardingScaffold(
|
return OnboardingScaffold(
|
||||||
header: 'Log in',
|
header: 'Log in',
|
||||||
subtitle:
|
subtitle:
|
||||||
'Frosty needs your permission in order to enable the ability to chat, view followed streams, and more.',
|
'Frosty needs permission to chat, view followed streams, and more.',
|
||||||
disclaimer:
|
disclaimer:
|
||||||
'Frosty only asks for the necessary permissions through the official Twitch API. You\'ll be able to review them before authorizing.',
|
'Frosty only asks for the necessary permissions through the official Twitch API. You\'ll be able to review them before authorizing.',
|
||||||
buttonText: 'Connect with Twitch',
|
buttonText: 'Connect with Twitch',
|
||||||
|
|
|
||||||
|
|
@ -46,62 +46,71 @@ class ProfileCard extends StatelessWidget {
|
||||||
userLogin: authStore.user.details!.login,
|
userLogin: authStore.user.details!.login,
|
||||||
radius: 12,
|
radius: 12,
|
||||||
),
|
),
|
||||||
title: GestureDetector(
|
title: Text.rich(
|
||||||
onTap: () {
|
TextSpan(
|
||||||
final navigator = Navigator.of(context);
|
text: authStore.user.details!.displayName,
|
||||||
showDialog(
|
children: [
|
||||||
context: context,
|
const WidgetSpan(child: SizedBox(width: 6)),
|
||||||
builder: (dialogContext) => FrostyDialog(
|
WidgetSpan(
|
||||||
title: 'Web session',
|
alignment: PlaceholderAlignment.middle,
|
||||||
message: hasToken
|
child: GestureDetector(
|
||||||
? 'Your Twitch web session is linked. When using the native player, ads will be avoided on channels where you have a subscription or Twitch Turbo.'
|
behavior: HitTestBehavior.opaque,
|
||||||
: 'Your Twitch web session is not linked. Log in again to avoid ads when using the native player on channels where you have a subscription or Twitch Turbo.',
|
onTap: () {
|
||||||
actions: hasToken
|
final navigator = Navigator.of(context);
|
||||||
? [
|
showDialog(
|
||||||
TextButton(
|
context: context,
|
||||||
onPressed: Navigator.of(dialogContext).pop,
|
builder: (dialogContext) => FrostyDialog(
|
||||||
child: const Text('OK'),
|
title: 'Web session',
|
||||||
),
|
message: hasToken
|
||||||
]
|
? 'Your Twitch web session is linked. When using the native player, ads will be avoided on channels where you have a subscription or Twitch Turbo.'
|
||||||
: [
|
: 'Your Twitch web session is not linked. Log in again to avoid ads when using the native player on channels where you have a subscription or Twitch Turbo.',
|
||||||
TextButton(
|
actions: hasToken
|
||||||
onPressed: Navigator.of(dialogContext).pop,
|
? [
|
||||||
child: const Text('Cancel'),
|
TextButton(
|
||||||
),
|
onPressed:
|
||||||
FilledButton(
|
Navigator.of(dialogContext).pop,
|
||||||
onPressed: () {
|
child: const Text('OK'),
|
||||||
Navigator.of(dialogContext).pop();
|
),
|
||||||
navigator.push(
|
]
|
||||||
MaterialPageRoute(
|
: [
|
||||||
builder: (context) => LoginWebView(),
|
TextButton(
|
||||||
),
|
onPressed:
|
||||||
);
|
Navigator.of(dialogContext).pop,
|
||||||
},
|
child: const Text('Cancel'),
|
||||||
child: const Text('Log in'),
|
),
|
||||||
),
|
FilledButton(
|
||||||
],
|
onPressed: () {
|
||||||
),
|
Navigator.of(dialogContext).pop();
|
||||||
);
|
navigator.push(
|
||||||
},
|
MaterialPageRoute(
|
||||||
child: Text.rich(
|
builder: (context) =>
|
||||||
TextSpan(
|
LoginWebView(),
|
||||||
text: authStore.user.details!.displayName,
|
),
|
||||||
children: [
|
);
|
||||||
const WidgetSpan(child: SizedBox(width: 6)),
|
},
|
||||||
WidgetSpan(
|
child: const Text('Log in'),
|
||||||
alignment: PlaceholderAlignment.middle,
|
),
|
||||||
child: Icon(
|
],
|
||||||
hasToken
|
),
|
||||||
? Icons.check_circle_outline_rounded
|
);
|
||||||
: Icons.info_outline_rounded,
|
},
|
||||||
size: 16,
|
// Padding enlarges the hit target without stealing taps
|
||||||
color: hasToken ? Colors.green : Colors.amber,
|
// on the display name / surrounding ListTile.
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
child: Icon(
|
||||||
|
hasToken
|
||||||
|
? Icons.check_circle_outline_rounded
|
||||||
|
: Icons.info_outline_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: hasToken ? Colors.green : Colors.amber,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
trailing: const Icon(Icons.chevron_right_rounded),
|
trailing: const Icon(Icons.chevron_right_rounded),
|
||||||
onTap: () => _showAccountOptionsModalBottomSheet(context),
|
onTap: () => _showAccountOptionsModalBottomSheet(context),
|
||||||
|
|
@ -111,7 +120,7 @@ class ProfileCard extends StatelessWidget {
|
||||||
leading: const Icon(Icons.no_accounts_rounded),
|
leading: const Icon(Icons.no_accounts_rounded),
|
||||||
title: const Text('Anonymous'),
|
title: const Text('Anonymous'),
|
||||||
subtitle: const Text(
|
subtitle: const Text(
|
||||||
'Log in to enable the ability to chat, view followed streams, and more.',
|
'Log in to chat, view followed streams, and more.',
|
||||||
),
|
),
|
||||||
trailing: const SizedBox(
|
trailing: const SizedBox(
|
||||||
height: double.infinity,
|
height: double.infinity,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
// import removed: flutter_colorpicker
|
|
||||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||||
|
import 'package:frosty/models/stream_languages.dart';
|
||||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||||
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
|
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
|
||||||
import 'package:frosty/widgets/accent_color_setting.dart';
|
import 'package:frosty/widgets/accent_color_setting.dart';
|
||||||
import 'package:frosty/widgets/external_browser_setting.dart';
|
import 'package:frosty/widgets/external_browser_setting.dart';
|
||||||
// import removed: frosty/widgets/dialog.dart
|
|
||||||
import 'package:frosty/widgets/section_header.dart';
|
import 'package:frosty/widgets/section_header.dart';
|
||||||
import 'package:frosty/widgets/settings_page_layout.dart';
|
import 'package:frosty/widgets/settings_page_layout.dart';
|
||||||
import 'package:frosty/widgets/theme_selection_setting.dart';
|
import 'package:frosty/widgets/theme_selection_setting.dart';
|
||||||
|
|
@ -34,6 +33,48 @@ class GeneralSettings extends StatelessWidget {
|
||||||
value: settingsStore.showThumbnails,
|
value: settingsStore.showThumbnails,
|
||||||
onChanged: (newValue) => settingsStore.showThumbnails = newValue,
|
onChanged: (newValue) => settingsStore.showThumbnails = newValue,
|
||||||
),
|
),
|
||||||
|
const SectionHeader('Stream languages'),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||||
|
child: Text(
|
||||||
|
settingsStore.streamLanguages.isEmpty
|
||||||
|
? 'Showing all languages. Select one or more to filter Top, category, and tag lists (like Twitch).'
|
||||||
|
: 'Only streams in the selected languages appear in Top, category, and tag lists.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
for (final entry in StreamLanguages.all)
|
||||||
|
FilterChip(
|
||||||
|
label: Text(entry.label),
|
||||||
|
selected: settingsStore.streamLanguages.contains(entry.code),
|
||||||
|
onSelected: (selected) {
|
||||||
|
final next = [...settingsStore.streamLanguages];
|
||||||
|
if (selected) {
|
||||||
|
if (!next.contains(entry.code)) next.add(entry.code);
|
||||||
|
} else {
|
||||||
|
next.remove(entry.code);
|
||||||
|
}
|
||||||
|
settingsStore.streamLanguages = next;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (settingsStore.streamLanguages.isNotEmpty)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
settingsStore.streamLanguages = const <String>[],
|
||||||
|
child: const Text('Clear language filter'),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SectionHeader('Links'),
|
const SectionHeader('Links'),
|
||||||
ExternalBrowserSetting(settingsStore: settingsStore),
|
ExternalBrowserSetting(settingsStore: settingsStore),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,12 @@ import 'package:flutter/material.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';
|
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/constants.dart';
|
import 'package:frosty/constants.dart';
|
||||||
import 'package:frosty/main.dart';
|
import 'package:frosty/main.dart';
|
||||||
import 'package:frosty/screens/settings/stores/user_store.dart';
|
import 'package:frosty/screens/settings/stores/user_store.dart';
|
||||||
import 'package:frosty/services/cookie_extractor.dart';
|
import 'package:frosty/services/cookie_extractor.dart';
|
||||||
|
import 'package:frosty/utils/twitch_oauth_scopes.dart' as twitch_scopes;
|
||||||
import 'package:frosty/widgets/frosty_dialog.dart';
|
import 'package:frosty/widgets/frosty_dialog.dart';
|
||||||
import 'package:mobx/mobx.dart';
|
import 'package:mobx/mobx.dart';
|
||||||
import 'package:webview_flutter/webview_flutter.dart';
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
|
|
@ -31,11 +33,17 @@ abstract class AuthBase with Store {
|
||||||
/// The secure storage key for the GQL web cookie token.
|
/// The secure storage key for the GQL web cookie token.
|
||||||
static const _gqlTokenKey = 'gql_token';
|
static const _gqlTokenKey = 'gql_token';
|
||||||
|
|
||||||
|
/// Twitch user id that opted into moderator scopes on this device.
|
||||||
|
static const _moderatorOptInUserIdKey = 'moderator_scopes_opt_in_user_id';
|
||||||
|
|
||||||
|
/// Last logged-in Twitch user id (kept across logout for re-login scope choice).
|
||||||
|
static const _lastUserIdKey = 'last_user_id';
|
||||||
|
|
||||||
/// The Twitch API service for making requests.
|
/// The Twitch API service for making requests.
|
||||||
final TwitchApi twitchApi;
|
final TwitchApi twitchApi;
|
||||||
|
|
||||||
/// Whether the token is valid or not.
|
/// Web GQL client (follow/unfollow + playback tokens).
|
||||||
var _tokenIsValid = false;
|
final TwitchGqlApi twitchGqlApi;
|
||||||
|
|
||||||
/// Timer used to retry authentication when offline or on transient failures.
|
/// Timer used to retry authentication when offline or on transient failures.
|
||||||
Timer? _reconnectTimer;
|
Timer? _reconnectTimer;
|
||||||
|
|
@ -47,7 +55,7 @@ abstract class AuthBase with Store {
|
||||||
static const _maxReconnectAttempts = 5;
|
static const _maxReconnectAttempts = 5;
|
||||||
|
|
||||||
/// The MobX store containing information relevant to the current user.
|
/// The MobX store containing information relevant to the current user.
|
||||||
final UserStore user;
|
late final UserStore user;
|
||||||
|
|
||||||
/// The user token used to authenticate with the Twitch API.
|
/// The user token used to authenticate with the Twitch API.
|
||||||
@readonly
|
@readonly
|
||||||
|
|
@ -61,6 +69,21 @@ abstract class AuthBase with Store {
|
||||||
@readonly
|
@readonly
|
||||||
var _isLoggedIn = false;
|
var _isLoggedIn = false;
|
||||||
|
|
||||||
|
/// OAuth scopes granted to the current user token.
|
||||||
|
@readonly
|
||||||
|
var _grantedScopes = ObservableList<String>();
|
||||||
|
|
||||||
|
/// Twitch user id that previously enabled moderator tools on this device.
|
||||||
|
String? _moderatorOptInUserId;
|
||||||
|
|
||||||
|
/// Last known Twitch user id, retained after logout for scope selection.
|
||||||
|
String? _lastUserId;
|
||||||
|
|
||||||
|
/// Whether the current token includes every moderator scope Frosty needs.
|
||||||
|
@computed
|
||||||
|
bool get hasModeratorScopes =>
|
||||||
|
twitch_scopes.hasModeratorScopes(_grantedScopes);
|
||||||
|
|
||||||
/// Authentication headers for Twitch API requests.
|
/// Authentication headers for Twitch API requests.
|
||||||
@computed
|
@computed
|
||||||
Map<String, String> get headersTwitch => {
|
Map<String, String> get headersTwitch => {
|
||||||
|
|
@ -76,6 +99,7 @@ abstract class AuthBase with Store {
|
||||||
FutureOr<NavigationDecision> handleNavigation({
|
FutureOr<NavigationDecision> handleNavigation({
|
||||||
required NavigationRequest request,
|
required NavigationRequest request,
|
||||||
Widget? routeAfter,
|
Widget? routeAfter,
|
||||||
|
bool upgradeModeratorScopes = false,
|
||||||
}) {
|
}) {
|
||||||
// Check if the URL is the redirect URI.
|
// Check if the URL is the redirect URI.
|
||||||
if (request.url.startsWith('https://twitch.tv/login')) {
|
if (request.url.startsWith('https://twitch.tv/login')) {
|
||||||
|
|
@ -83,15 +107,27 @@ abstract class AuthBase with Store {
|
||||||
final uri = Uri.parse(request.url.replaceFirst('#', '?'));
|
final uri = Uri.parse(request.url.replaceFirst('#', '?'));
|
||||||
final token = uri.queryParameters['access_token'];
|
final token = uri.queryParameters['access_token'];
|
||||||
|
|
||||||
// Login with the provided token.
|
if (token != null) {
|
||||||
if (token != null) login(token: token);
|
if (upgradeModeratorScopes) {
|
||||||
|
unawaited(completeModeratorUpgrade(token: token));
|
||||||
|
} else {
|
||||||
|
unawaited(login(token: token));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the the URL has been redirected to "https://www.twitch.tv/?no-reload=true".
|
// Check if the URL has been redirected to "https://www.twitch.tv/?no-reload=true".
|
||||||
// When redirected to the redirect_uri, there will be another redirect to "https://www.twitch.tv/?no-reload=true".
|
// When redirected to the redirect_uri, there will be another redirect to "https://www.twitch.tv/?no-reload=true".
|
||||||
// Checking for this will ensure that the user has automatically logged in to Twitch on the WebView itself.
|
// Checking for this will ensure that the user has automatically logged in to Twitch on the WebView itself.
|
||||||
if (request.url == 'https://www.twitch.tv/?no-reload=true') {
|
if (request.url == 'https://www.twitch.tv/?no-reload=true') {
|
||||||
_extractGqlToken();
|
_extractGqlToken();
|
||||||
|
|
||||||
|
// Upgrade WebViews pop themselves after an atomic commit so we never
|
||||||
|
// dismiss into a half-applied session.
|
||||||
|
if (upgradeModeratorScopes) {
|
||||||
|
return NavigationDecision.navigate;
|
||||||
|
}
|
||||||
|
|
||||||
if (routeAfter != null) {
|
if (routeAfter != null) {
|
||||||
navigatorKey.currentState?.pop();
|
navigatorKey.currentState?.pop();
|
||||||
navigatorKey.currentState?.push(
|
navigatorKey.currentState?.push(
|
||||||
|
|
@ -107,7 +143,10 @@ abstract class AuthBase with Store {
|
||||||
return NavigationDecision.navigate;
|
return NavigationDecision.navigate;
|
||||||
}
|
}
|
||||||
|
|
||||||
WebViewController createAuthWebViewController({Widget? routeAfter}) {
|
WebViewController createAuthWebViewController({
|
||||||
|
Widget? routeAfter,
|
||||||
|
bool upgradeModeratorScopes = false,
|
||||||
|
}) {
|
||||||
final webViewController = WebViewController()
|
final webViewController = WebViewController()
|
||||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||||
// Use platform-specific user agents to allow Google OAuth sign-in.
|
// Use platform-specific user agents to allow Google OAuth sign-in.
|
||||||
|
|
@ -119,11 +158,18 @@ abstract class AuthBase with Store {
|
||||||
: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Mobile Safari/537.36',
|
: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Mobile Safari/537.36',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final scopeQuery = upgradeModeratorScopes || _shouldIncludeModeratorScopes
|
||||||
|
? twitch_scopes.twitchAllUserScopeQuery
|
||||||
|
: twitch_scopes.twitchUserScopeQuery;
|
||||||
|
|
||||||
return webViewController
|
return webViewController
|
||||||
..setNavigationDelegate(
|
..setNavigationDelegate(
|
||||||
NavigationDelegate(
|
NavigationDelegate(
|
||||||
onNavigationRequest: (request) =>
|
onNavigationRequest: (request) => handleNavigation(
|
||||||
handleNavigation(request: request, routeAfter: routeAfter),
|
request: request,
|
||||||
|
routeAfter: routeAfter,
|
||||||
|
upgradeModeratorScopes: upgradeModeratorScopes,
|
||||||
|
),
|
||||||
onWebResourceError: (error) {
|
onWebResourceError: (error) {
|
||||||
debugPrint('Auth WebView error: ${error.description}');
|
debugPrint('Auth WebView error: ${error.description}');
|
||||||
},
|
},
|
||||||
|
|
@ -170,8 +216,7 @@ abstract class AuthBase with Store {
|
||||||
'client_id': clientId,
|
'client_id': clientId,
|
||||||
'redirect_uri': 'https://twitch.tv/login',
|
'redirect_uri': 'https://twitch.tv/login',
|
||||||
'response_type': 'token',
|
'response_type': 'token',
|
||||||
'scope':
|
'scope': scopeQuery,
|
||||||
'chat:read chat:edit user:read:follows user:read:blocked_users user:manage:blocked_users user:manage:chat_color user:read:moderated_channels moderator:manage:chat_messages moderator:manage:banned_users',
|
|
||||||
'force_verify': 'true',
|
'force_verify': 'true',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -218,7 +263,30 @@ abstract class AuthBase with Store {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
AuthBase({required this.twitchApi}) : user = UserStore(twitchApi: twitchApi);
|
AuthBase({
|
||||||
|
required this.twitchApi,
|
||||||
|
required this.twitchGqlApi,
|
||||||
|
}) {
|
||||||
|
user = UserStore(
|
||||||
|
twitchApi: twitchApi,
|
||||||
|
twitchGqlApi: twitchGqlApi,
|
||||||
|
hasModeratorScopes: () => hasModeratorScopes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _shouldIncludeModeratorScopes {
|
||||||
|
final currentId = user.details?.id;
|
||||||
|
if (currentId != null && currentId == _moderatorOptInUserId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// After logout, still request mod scopes for the same opted-in account.
|
||||||
|
if (currentId == null &&
|
||||||
|
_lastUserId != null &&
|
||||||
|
_lastUserId == _moderatorOptInUserId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _extractGqlToken() async {
|
Future<void> _extractGqlToken() async {
|
||||||
try {
|
try {
|
||||||
|
|
@ -243,13 +311,40 @@ abstract class AuthBase with Store {
|
||||||
await _storage.delete(key: _gqlTokenKey);
|
await _storage.delete(key: _gqlTokenKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _setGrantedScopes(List<String> scopes) {
|
||||||
|
_grantedScopes = ObservableList.of(scopes);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistModeratorOptIn(String userId) async {
|
||||||
|
if (_moderatorOptInUserId == userId) return;
|
||||||
|
_moderatorOptInUserId = userId;
|
||||||
|
await _storage.write(key: _moderatorOptInUserIdKey, value: userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistLastUserId(String? userId) async {
|
||||||
|
if (_lastUserId == userId) return;
|
||||||
|
_lastUserId = userId;
|
||||||
|
if (userId == null) {
|
||||||
|
await _storage.delete(key: _lastUserIdKey);
|
||||||
|
} else {
|
||||||
|
await _storage.write(key: _lastUserIdKey, value: userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Initialize by retrieving a token if it does not already exist.
|
/// Initialize by retrieving a token if it does not already exist.
|
||||||
@action
|
@action
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
try {
|
try {
|
||||||
// Read and set the currently stored user token, if any.
|
final stored = await Future.wait([
|
||||||
_token = await _storage.read(key: _userTokenKey);
|
_storage.read(key: _moderatorOptInUserIdKey),
|
||||||
_gqlToken = await _storage.read(key: _gqlTokenKey);
|
_storage.read(key: _lastUserIdKey),
|
||||||
|
_storage.read(key: _userTokenKey),
|
||||||
|
_storage.read(key: _gqlTokenKey),
|
||||||
|
]);
|
||||||
|
_moderatorOptInUserId = stored[0];
|
||||||
|
_lastUserId = stored[1];
|
||||||
|
_token = stored[2];
|
||||||
|
_gqlToken = stored[3];
|
||||||
|
|
||||||
// If the token does not exist, get the default token.
|
// If the token does not exist, get the default token.
|
||||||
// Otherwise, log in.
|
// Otherwise, log in.
|
||||||
|
|
@ -257,29 +352,35 @@ abstract class AuthBase with Store {
|
||||||
// Retrieve the currently stored default token if it exists.
|
// Retrieve the currently stored default token if it exists.
|
||||||
_token = await _storage.read(key: _defaultTokenKey);
|
_token = await _storage.read(key: _defaultTokenKey);
|
||||||
// If the token does not exist or is invalid, get a new token and store it.
|
// If the token does not exist or is invalid, get a new token and store it.
|
||||||
if (_token == null || !await twitchApi.validateToken(token: _token!)) {
|
if (_token == null ||
|
||||||
|
await twitchApi.validateToken(token: _token!) == null) {
|
||||||
_token = await twitchApi.getDefaultToken();
|
_token = await twitchApi.getDefaultToken();
|
||||||
await _storage.write(key: _defaultTokenKey, value: _token);
|
await _storage.write(key: _defaultTokenKey, value: _token);
|
||||||
}
|
}
|
||||||
|
_setGrantedScopes(const []);
|
||||||
} else {
|
} else {
|
||||||
// Validate the existing token. If it fails, start reconnect loop.
|
// Validate the existing token. If it fails, start reconnect loop.
|
||||||
|
final TwitchTokenInfo info;
|
||||||
try {
|
try {
|
||||||
_tokenIsValid = await twitchApi.validateToken(token: _token!);
|
final validated = await twitchApi.validateToken(token: _token!);
|
||||||
|
if (validated == null) return await logout();
|
||||||
|
info = validated;
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
debugPrint('Token validation failed: $e');
|
debugPrint('Token validation failed: $e');
|
||||||
_isLoggedIn = false;
|
_isLoggedIn = false;
|
||||||
|
_setGrantedScopes(const []);
|
||||||
_startReconnectLoop();
|
_startReconnectLoop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the token is invalid, logout.
|
_setGrantedScopes(info.scopes);
|
||||||
if (!_tokenIsValid) return await logout();
|
|
||||||
|
|
||||||
// Initialize the user store.
|
// Initialize the user store.
|
||||||
await user.init();
|
await user.init();
|
||||||
|
|
||||||
if (user.details != null) {
|
if (user.details != null) {
|
||||||
_isLoggedIn = true;
|
_isLoggedIn = true;
|
||||||
|
await _persistLastUserId(user.details!.id);
|
||||||
_stopReconnectLoop();
|
_stopReconnectLoop();
|
||||||
|
|
||||||
// Recover a missed web session link — the WebView cookie store may
|
// Recover a missed web session link — the WebView cookie store may
|
||||||
|
|
@ -301,8 +402,9 @@ abstract class AuthBase with Store {
|
||||||
Future<void> login({required String token}) async {
|
Future<void> login({required String token}) async {
|
||||||
try {
|
try {
|
||||||
// Validate the custom token.
|
// Validate the custom token.
|
||||||
_tokenIsValid = await twitchApi.validateToken(token: token);
|
final info = await twitchApi.validateToken(token: token);
|
||||||
if (!_tokenIsValid) return;
|
if (info == null) return;
|
||||||
|
_setGrantedScopes(info.scopes);
|
||||||
|
|
||||||
// Replace the current default token with the new custom token.
|
// Replace the current default token with the new custom token.
|
||||||
_token = token;
|
_token = token;
|
||||||
|
|
@ -316,6 +418,12 @@ abstract class AuthBase with Store {
|
||||||
// Set the login status to logged in.
|
// Set the login status to logged in.
|
||||||
if (user.details != null) {
|
if (user.details != null) {
|
||||||
_isLoggedIn = true;
|
_isLoggedIn = true;
|
||||||
|
await _persistLastUserId(user.details!.id);
|
||||||
|
// A fresh login that already includes mod scopes (re-auth after opt-in)
|
||||||
|
// should keep the opt-in flag for this account.
|
||||||
|
if (hasModeratorScopes) {
|
||||||
|
await _persistModeratorOptIn(user.details!.id);
|
||||||
|
}
|
||||||
FirebaseCrashlytics.instance.setCustomKey('is_logged_in', true);
|
FirebaseCrashlytics.instance.setCustomKey('is_logged_in', true);
|
||||||
FirebaseCrashlytics.instance.setUserIdentifier(user.details!.id);
|
FirebaseCrashlytics.instance.setUserIdentifier(user.details!.id);
|
||||||
_stopReconnectLoop();
|
_stopReconnectLoop();
|
||||||
|
|
@ -325,16 +433,96 @@ abstract class AuthBase with Store {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Atomically upgrades the current session with moderator OAuth scopes.
|
||||||
|
///
|
||||||
|
/// Keeps the existing token until validation proves the new token is for the
|
||||||
|
/// same user and includes every moderator scope. On any failure, the old
|
||||||
|
/// session is left untouched.
|
||||||
|
@action
|
||||||
|
Future<bool> completeModeratorUpgrade({required String token}) async {
|
||||||
|
final currentUserId = user.details?.id;
|
||||||
|
if (currentUserId == null || !_isLoggedIn) {
|
||||||
|
_showModeratorUpgradeFailed(
|
||||||
|
'Log in first, then enable moderator tools from a channel you moderate.',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final info = await twitchApi.validateToken(token: token);
|
||||||
|
if (info == null) {
|
||||||
|
_showModeratorUpgradeFailed(
|
||||||
|
'Could not validate the new Twitch permissions. Your current session was kept.',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.userId != currentUserId) {
|
||||||
|
_showModeratorUpgradeFailed(
|
||||||
|
'Log in with the same Twitch account to enable moderator tools. Your current session was kept.',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!twitch_scopes.hasModeratorScopes(info.scopes)) {
|
||||||
|
_showModeratorUpgradeFailed(
|
||||||
|
'Moderator permissions were not granted. Your current session was kept.',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit only after all gates pass.
|
||||||
|
_token = token;
|
||||||
|
_setGrantedScopes(info.scopes);
|
||||||
|
await Future.wait([
|
||||||
|
_storage.write(key: _userTokenKey, value: token),
|
||||||
|
_persistModeratorOptIn(currentUserId),
|
||||||
|
]);
|
||||||
|
await user.refreshModeratedChannels();
|
||||||
|
|
||||||
|
navigatorKey.currentState?.pop();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Moderator upgrade failed due to $e');
|
||||||
|
_showModeratorUpgradeFailed(
|
||||||
|
'Could not enable moderator tools. Your current session was kept.',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showModeratorUpgradeFailed(String message) {
|
||||||
|
final context = navigatorKey.currentContext;
|
||||||
|
if (context == null) return;
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => FrostyDialog(
|
||||||
|
title: 'Moderator tools not enabled',
|
||||||
|
message: message,
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: Navigator.of(dialogContext).pop,
|
||||||
|
child: const Text('OK'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Logs out the current user and updates fields accordingly.
|
/// Logs out the current user and updates fields accordingly.
|
||||||
@action
|
@action
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
try {
|
try {
|
||||||
_stopReconnectLoop();
|
_stopReconnectLoop();
|
||||||
|
final previousUserId = user.details?.id ?? _lastUserId;
|
||||||
|
await _persistLastUserId(previousUserId);
|
||||||
|
|
||||||
// Delete the existing user token and GQL token.
|
// Delete the existing user token and GQL token.
|
||||||
await _storage.delete(key: _userTokenKey);
|
await _storage.delete(key: _userTokenKey);
|
||||||
await _storage.delete(key: _gqlTokenKey);
|
await _storage.delete(key: _gqlTokenKey);
|
||||||
_token = null;
|
_token = null;
|
||||||
_gqlToken = null;
|
_gqlToken = null;
|
||||||
|
_setGrantedScopes(const []);
|
||||||
|
|
||||||
// Clear the user info.
|
// Clear the user info.
|
||||||
user.dispose();
|
user.dispose();
|
||||||
|
|
@ -343,7 +531,8 @@ abstract class AuthBase with Store {
|
||||||
_token = await _storage.read(key: _defaultTokenKey);
|
_token = await _storage.read(key: _defaultTokenKey);
|
||||||
|
|
||||||
// If the default token does not already exist or it's invalid, get the new default token and store it.
|
// If the default token does not already exist or it's invalid, get the new default token and store it.
|
||||||
if (_token == null || !await twitchApi.validateToken(token: _token!)) {
|
if (_token == null ||
|
||||||
|
await twitchApi.validateToken(token: _token!) == null) {
|
||||||
_token = await twitchApi.getDefaultToken();
|
_token = await twitchApi.getDefaultToken();
|
||||||
await _storage.write(key: _defaultTokenKey, value: _token);
|
await _storage.write(key: _defaultTokenKey, value: _token);
|
||||||
}
|
}
|
||||||
|
|
@ -379,17 +568,19 @@ abstract class AuthBase with Store {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final isValid = await twitchApi.validateToken(token: stored);
|
final info = await twitchApi.validateToken(token: stored);
|
||||||
if (!isValid) {
|
if (info == null) {
|
||||||
await logout();
|
await logout();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Token valid again — restore session.
|
// Token valid again — restore session.
|
||||||
_token = stored;
|
_token = stored;
|
||||||
|
_setGrantedScopes(info.scopes);
|
||||||
await user.init();
|
await user.init();
|
||||||
if (user.details != null) {
|
if (user.details != null) {
|
||||||
_isLoggedIn = true;
|
_isLoggedIn = true;
|
||||||
|
await _persistLastUserId(user.details!.id);
|
||||||
_error = null;
|
_error = null;
|
||||||
_stopReconnectLoop();
|
_stopReconnectLoop();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,14 @@ part of 'auth_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 _$AuthStore on AuthBase, Store {
|
mixin _$AuthStore on AuthBase, Store {
|
||||||
|
Computed<bool>? _$hasModeratorScopesComputed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get hasModeratorScopes =>
|
||||||
|
(_$hasModeratorScopesComputed ??= Computed<bool>(
|
||||||
|
() => super.hasModeratorScopes,
|
||||||
|
name: 'AuthBase.hasModeratorScopes',
|
||||||
|
)).value;
|
||||||
Computed<Map<String, String>>? _$headersTwitchComputed;
|
Computed<Map<String, String>>? _$headersTwitchComputed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -75,6 +83,26 @@ mixin _$AuthStore on AuthBase, Store {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
late final _$_grantedScopesAtom = Atom(
|
||||||
|
name: 'AuthBase._grantedScopes',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
ObservableList<String> get grantedScopes {
|
||||||
|
_$_grantedScopesAtom.reportRead();
|
||||||
|
return super._grantedScopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
ObservableList<String> get _grantedScopes => grantedScopes;
|
||||||
|
|
||||||
|
@override
|
||||||
|
set _grantedScopes(ObservableList<String> value) {
|
||||||
|
_$_grantedScopesAtom.reportWrite(value, super._grantedScopes, () {
|
||||||
|
super._grantedScopes = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
late final _$_errorAtom = Atom(name: 'AuthBase._error', context: context);
|
late final _$_errorAtom = Atom(name: 'AuthBase._error', context: context);
|
||||||
|
|
||||||
String? get error {
|
String? get error {
|
||||||
|
|
@ -121,6 +149,18 @@ mixin _$AuthStore on AuthBase, Store {
|
||||||
return _$loginAsyncAction.run(() => super.login(token: token));
|
return _$loginAsyncAction.run(() => super.login(token: token));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
late final _$completeModeratorUpgradeAsyncAction = AsyncAction(
|
||||||
|
'AuthBase.completeModeratorUpgrade',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> completeModeratorUpgrade({required String token}) {
|
||||||
|
return _$completeModeratorUpgradeAsyncAction.run(
|
||||||
|
() => super.completeModeratorUpgrade(token: token),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
late final _$logoutAsyncAction = AsyncAction(
|
late final _$logoutAsyncAction = AsyncAction(
|
||||||
'AuthBase.logout',
|
'AuthBase.logout',
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -134,6 +174,7 @@ mixin _$AuthStore on AuthBase, Store {
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return '''
|
return '''
|
||||||
|
hasModeratorScopes: ${hasModeratorScopes},
|
||||||
headersTwitch: ${headersTwitch}
|
headersTwitch: ${headersTwitch}
|
||||||
''';
|
''';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:frosty/apis/twitch_gql_api.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:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
import 'package:mobx/mobx.dart';
|
import 'package:mobx/mobx.dart';
|
||||||
|
|
@ -8,8 +9,11 @@ part 'settings_store.g.dart';
|
||||||
class SettingsStore extends _SettingsStoreBase with _$SettingsStore {
|
class SettingsStore extends _SettingsStoreBase with _$SettingsStore {
|
||||||
SettingsStore();
|
SettingsStore();
|
||||||
|
|
||||||
factory SettingsStore.fromJson(Map<String, dynamic> json) =>
|
factory SettingsStore.fromJson(Map<String, dynamic> json) {
|
||||||
_$SettingsStoreFromJson(json);
|
final store = _$SettingsStoreFromJson(json);
|
||||||
|
store._migrateNativePlayerDefault();
|
||||||
|
return store;
|
||||||
|
}
|
||||||
Map<String, dynamic> toJson() => _$SettingsStoreToJson(this);
|
Map<String, dynamic> toJson() => _$SettingsStoreToJson(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,6 +53,24 @@ abstract class _SettingsStoreBase with Store {
|
||||||
@observable
|
@observable
|
||||||
var launchUrlExternal = defaultLaunchUrlExternal;
|
var launchUrlExternal = defaultLaunchUrlExternal;
|
||||||
|
|
||||||
|
/// ISO 639-1 language codes for Top / category / tag stream lists.
|
||||||
|
/// Empty = show all languages (no filter). Default Deutsch like a DE Twitch account.
|
||||||
|
static const defaultStreamLanguages = <String>['de'];
|
||||||
|
|
||||||
|
@JsonKey(defaultValue: defaultStreamLanguages)
|
||||||
|
@observable
|
||||||
|
var streamLanguages = defaultStreamLanguages;
|
||||||
|
|
||||||
|
/// Persistent viewer sort for Top / category / tag directories.
|
||||||
|
static const defaultStreamViewerSort = StreamViewerSort.viewersDesc;
|
||||||
|
|
||||||
|
@JsonKey(
|
||||||
|
defaultValue: defaultStreamViewerSort,
|
||||||
|
unknownEnumValue: StreamViewerSort.viewersDesc,
|
||||||
|
)
|
||||||
|
@observable
|
||||||
|
var streamViewerSort = defaultStreamViewerSort;
|
||||||
|
|
||||||
@action
|
@action
|
||||||
void resetGeneralSettings() {
|
void resetGeneralSettings() {
|
||||||
themeType = defaultThemeType;
|
themeType = defaultThemeType;
|
||||||
|
|
@ -58,6 +80,8 @@ abstract class _SettingsStoreBase with Store {
|
||||||
showThumbnails = defaultShowThumbnails;
|
showThumbnails = defaultShowThumbnails;
|
||||||
|
|
||||||
launchUrlExternal = defaultLaunchUrlExternal;
|
launchUrlExternal = defaultLaunchUrlExternal;
|
||||||
|
streamLanguages = defaultStreamLanguages;
|
||||||
|
streamViewerSort = defaultStreamViewerSort;
|
||||||
}
|
}
|
||||||
|
|
||||||
// * Video Settings
|
// * Video Settings
|
||||||
|
|
@ -65,7 +89,8 @@ abstract class _SettingsStoreBase with Store {
|
||||||
static const defaultShowVideo = true;
|
static const defaultShowVideo = true;
|
||||||
static const defaultDefaultToHighestQuality = false;
|
static const defaultDefaultToHighestQuality = false;
|
||||||
static const defaultUseTextureRendering = true;
|
static const defaultUseTextureRendering = true;
|
||||||
static const defaultUseNativePlayer = false;
|
static const defaultUseNativePlayer = true;
|
||||||
|
static const defaultBlockTwitchAds = true;
|
||||||
|
|
||||||
static const defaultShowOverlay = true;
|
static const defaultShowOverlay = true;
|
||||||
static const defaultToggleableOverlay = false;
|
static const defaultToggleableOverlay = false;
|
||||||
|
|
@ -88,6 +113,25 @@ abstract class _SettingsStoreBase with Store {
|
||||||
@observable
|
@observable
|
||||||
var useNativePlayer = defaultUseNativePlayer;
|
var useNativePlayer = defaultUseNativePlayer;
|
||||||
|
|
||||||
|
/// TwitchAdSolutions (vaft)-style ad blocking for the native player, and
|
||||||
|
/// userscript injection for the legacy WebView player.
|
||||||
|
@JsonKey(defaultValue: defaultBlockTwitchAds)
|
||||||
|
@observable
|
||||||
|
var blockTwitchAds = defaultBlockTwitchAds;
|
||||||
|
|
||||||
|
/// 5.2.0 made native the default. 5.1.0 already wrote
|
||||||
|
/// `useNativePlayer: false` into saved settings, so a Dart default
|
||||||
|
/// change alone would leave existing installs on WebView.
|
||||||
|
@JsonKey(defaultValue: false)
|
||||||
|
var nativePlayerV52Migrated = false;
|
||||||
|
|
||||||
|
@action
|
||||||
|
void _migrateNativePlayerDefault() {
|
||||||
|
if (nativePlayerV52Migrated) return;
|
||||||
|
useNativePlayer = true;
|
||||||
|
nativePlayerV52Migrated = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Overlay options
|
// Overlay options
|
||||||
@JsonKey(defaultValue: defaultShowOverlay)
|
@JsonKey(defaultValue: defaultShowOverlay)
|
||||||
@observable
|
@observable
|
||||||
|
|
@ -107,6 +151,7 @@ abstract class _SettingsStoreBase with Store {
|
||||||
defaultToHighestQuality = defaultDefaultToHighestQuality;
|
defaultToHighestQuality = defaultDefaultToHighestQuality;
|
||||||
useTextureRendering = defaultUseTextureRendering;
|
useTextureRendering = defaultUseTextureRendering;
|
||||||
useNativePlayer = defaultUseNativePlayer;
|
useNativePlayer = defaultUseNativePlayer;
|
||||||
|
blockTwitchAds = defaultBlockTwitchAds;
|
||||||
|
|
||||||
showOverlay = defaultShowOverlay;
|
showOverlay = defaultShowOverlay;
|
||||||
toggleableOverlay = defaultToggleableOverlay;
|
toggleableOverlay = defaultToggleableOverlay;
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,24 @@ SettingsStore _$SettingsStoreFromJson(
|
||||||
..showThumbnails = json['showThumbnails'] as bool? ?? true
|
..showThumbnails = json['showThumbnails'] as bool? ?? true
|
||||||
..largeStreamCard = json['largeStreamCard'] as bool? ?? false
|
..largeStreamCard = json['largeStreamCard'] as bool? ?? false
|
||||||
..launchUrlExternal = json['launchUrlExternal'] as bool? ?? false
|
..launchUrlExternal = json['launchUrlExternal'] as bool? ?? false
|
||||||
|
..streamLanguages =
|
||||||
|
(json['streamLanguages'] as List<dynamic>?)
|
||||||
|
?.map((e) => e as String)
|
||||||
|
.toList() ??
|
||||||
|
['de']
|
||||||
|
..streamViewerSort =
|
||||||
|
$enumDecodeNullable(
|
||||||
|
_$StreamViewerSortEnumMap,
|
||||||
|
json['streamViewerSort'],
|
||||||
|
unknownValue: StreamViewerSort.viewersDesc,
|
||||||
|
) ??
|
||||||
|
StreamViewerSort.viewersDesc
|
||||||
..showVideo = json['showVideo'] as bool? ?? true
|
..showVideo = json['showVideo'] as bool? ?? true
|
||||||
..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false
|
..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false
|
||||||
..useTextureRendering = json['useTextureRendering'] as bool? ?? true
|
..useTextureRendering = json['useTextureRendering'] as bool? ?? true
|
||||||
..useNativePlayer = json['useNativePlayer'] as bool? ?? false
|
..useNativePlayer = json['useNativePlayer'] as bool? ?? true
|
||||||
|
..blockTwitchAds = json['blockTwitchAds'] as bool? ?? true
|
||||||
|
..nativePlayerV52Migrated = json['nativePlayerV52Migrated'] as bool? ?? false
|
||||||
..showOverlay = json['showOverlay'] as bool? ?? true
|
..showOverlay = json['showOverlay'] as bool? ?? true
|
||||||
..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false
|
..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false
|
||||||
..showLatency = json['showLatency'] as bool? ?? false
|
..showLatency = json['showLatency'] as bool? ?? false
|
||||||
|
|
@ -102,10 +116,14 @@ Map<String, dynamic> _$SettingsStoreToJson(
|
||||||
'showThumbnails': instance.showThumbnails,
|
'showThumbnails': instance.showThumbnails,
|
||||||
'largeStreamCard': instance.largeStreamCard,
|
'largeStreamCard': instance.largeStreamCard,
|
||||||
'launchUrlExternal': instance.launchUrlExternal,
|
'launchUrlExternal': instance.launchUrlExternal,
|
||||||
|
'streamLanguages': instance.streamLanguages,
|
||||||
|
'streamViewerSort': _$StreamViewerSortEnumMap[instance.streamViewerSort]!,
|
||||||
'showVideo': instance.showVideo,
|
'showVideo': instance.showVideo,
|
||||||
'defaultToHighestQuality': instance.defaultToHighestQuality,
|
'defaultToHighestQuality': instance.defaultToHighestQuality,
|
||||||
'useTextureRendering': instance.useTextureRendering,
|
'useTextureRendering': instance.useTextureRendering,
|
||||||
'useNativePlayer': instance.useNativePlayer,
|
'useNativePlayer': instance.useNativePlayer,
|
||||||
|
'blockTwitchAds': instance.blockTwitchAds,
|
||||||
|
'nativePlayerV52Migrated': instance.nativePlayerV52Migrated,
|
||||||
'showOverlay': instance.showOverlay,
|
'showOverlay': instance.showOverlay,
|
||||||
'toggleableOverlay': instance.toggleableOverlay,
|
'toggleableOverlay': instance.toggleableOverlay,
|
||||||
'showLatency': instance.showLatency,
|
'showLatency': instance.showLatency,
|
||||||
|
|
@ -155,6 +173,11 @@ const _$ThemeTypeEnumMap = {
|
||||||
ThemeType.dark: 'dark',
|
ThemeType.dark: 'dark',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _$StreamViewerSortEnumMap = {
|
||||||
|
StreamViewerSort.viewersDesc: 'viewersDesc',
|
||||||
|
StreamViewerSort.viewersAsc: 'viewersAsc',
|
||||||
|
};
|
||||||
|
|
||||||
const _$TimestampTypeEnumMap = {
|
const _$TimestampTypeEnumMap = {
|
||||||
TimestampType.disabled: 'disabled',
|
TimestampType.disabled: 'disabled',
|
||||||
TimestampType.twelve: 'twelve',
|
TimestampType.twelve: 'twelve',
|
||||||
|
|
@ -274,6 +297,42 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
late final _$streamLanguagesAtom = Atom(
|
||||||
|
name: '_SettingsStoreBase.streamLanguages',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get streamLanguages {
|
||||||
|
_$streamLanguagesAtom.reportRead();
|
||||||
|
return super.streamLanguages;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
set streamLanguages(List<String> value) {
|
||||||
|
_$streamLanguagesAtom.reportWrite(value, super.streamLanguages, () {
|
||||||
|
super.streamLanguages = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$streamViewerSortAtom = Atom(
|
||||||
|
name: '_SettingsStoreBase.streamViewerSort',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
StreamViewerSort get streamViewerSort {
|
||||||
|
_$streamViewerSortAtom.reportRead();
|
||||||
|
return super.streamViewerSort;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
set streamViewerSort(StreamViewerSort value) {
|
||||||
|
_$streamViewerSortAtom.reportWrite(value, super.streamViewerSort, () {
|
||||||
|
super.streamViewerSort = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
late final _$showVideoAtom = Atom(
|
late final _$showVideoAtom = Atom(
|
||||||
name: '_SettingsStoreBase.showVideo',
|
name: '_SettingsStoreBase.showVideo',
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -350,6 +409,24 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
late final _$blockTwitchAdsAtom = Atom(
|
||||||
|
name: '_SettingsStoreBase.blockTwitchAds',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get blockTwitchAds {
|
||||||
|
_$blockTwitchAdsAtom.reportRead();
|
||||||
|
return super.blockTwitchAds;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
set blockTwitchAds(bool value) {
|
||||||
|
_$blockTwitchAdsAtom.reportWrite(value, super.blockTwitchAds, () {
|
||||||
|
super.blockTwitchAds = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
late final _$showOverlayAtom = Atom(
|
late final _$showOverlayAtom = Atom(
|
||||||
name: '_SettingsStoreBase.showOverlay',
|
name: '_SettingsStoreBase.showOverlay',
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -1159,6 +1236,18 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void _migrateNativePlayerDefault() {
|
||||||
|
final _$actionInfo = _$_SettingsStoreBaseActionController.startAction(
|
||||||
|
name: '_SettingsStoreBase._migrateNativePlayerDefault',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return super._migrateNativePlayerDefault();
|
||||||
|
} finally {
|
||||||
|
_$_SettingsStoreBaseActionController.endAction(_$actionInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void resetVideoSettings() {
|
void resetVideoSettings() {
|
||||||
final _$actionInfo = _$_SettingsStoreBaseActionController.startAction(
|
final _$actionInfo = _$_SettingsStoreBaseActionController.startAction(
|
||||||
|
|
@ -1227,10 +1316,13 @@ accentColor: ${accentColor},
|
||||||
showThumbnails: ${showThumbnails},
|
showThumbnails: ${showThumbnails},
|
||||||
largeStreamCard: ${largeStreamCard},
|
largeStreamCard: ${largeStreamCard},
|
||||||
launchUrlExternal: ${launchUrlExternal},
|
launchUrlExternal: ${launchUrlExternal},
|
||||||
|
streamLanguages: ${streamLanguages},
|
||||||
|
streamViewerSort: ${streamViewerSort},
|
||||||
showVideo: ${showVideo},
|
showVideo: ${showVideo},
|
||||||
defaultToHighestQuality: ${defaultToHighestQuality},
|
defaultToHighestQuality: ${defaultToHighestQuality},
|
||||||
useTextureRendering: ${useTextureRendering},
|
useTextureRendering: ${useTextureRendering},
|
||||||
useNativePlayer: ${useNativePlayer},
|
useNativePlayer: ${useNativePlayer},
|
||||||
|
blockTwitchAds: ${blockTwitchAds},
|
||||||
showOverlay: ${showOverlay},
|
showOverlay: ${showOverlay},
|
||||||
toggleableOverlay: ${toggleableOverlay},
|
toggleableOverlay: ${toggleableOverlay},
|
||||||
showLatency: ${showLatency},
|
showLatency: ${showLatency},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
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/apis/twitch_integrity.dart';
|
||||||
import 'package:frosty/models/user.dart';
|
import 'package:frosty/models/user.dart';
|
||||||
import 'package:mobx/mobx.dart';
|
import 'package:mobx/mobx.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
part 'user_store.g.dart';
|
part 'user_store.g.dart';
|
||||||
|
|
||||||
|
|
@ -8,6 +14,10 @@ class UserStore = UserStoreBase with _$UserStore;
|
||||||
|
|
||||||
abstract class UserStoreBase with Store {
|
abstract class UserStoreBase with Store {
|
||||||
final TwitchApi twitchApi;
|
final TwitchApi twitchApi;
|
||||||
|
final TwitchGqlApi twitchGqlApi;
|
||||||
|
|
||||||
|
/// Whether the active auth token includes Frosty's moderator OAuth scopes.
|
||||||
|
final bool Function() hasModeratorScopes;
|
||||||
|
|
||||||
/// The current user's info.
|
/// The current user's info.
|
||||||
@readonly
|
@readonly
|
||||||
|
|
@ -21,9 +31,22 @@ abstract class UserStoreBase with Store {
|
||||||
@readonly
|
@readonly
|
||||||
var _moderatedChannels = ObservableList<String>();
|
var _moderatedChannels = ObservableList<String>();
|
||||||
|
|
||||||
|
/// Cached follow state for channels queried this session (id → following).
|
||||||
|
final _followCache = ObservableMap<String, bool>();
|
||||||
|
|
||||||
|
static const _deviceIdKey = 'twitch_gql_device_id';
|
||||||
|
|
||||||
|
String? _deviceId;
|
||||||
|
String? _integrityToken;
|
||||||
|
int _integrityExpirationMs = 0;
|
||||||
|
|
||||||
ReactionDisposer? _disposeReaction;
|
ReactionDisposer? _disposeReaction;
|
||||||
|
|
||||||
UserStoreBase({required this.twitchApi});
|
UserStoreBase({
|
||||||
|
required this.twitchApi,
|
||||||
|
required this.twitchGqlApi,
|
||||||
|
required this.hasModeratorScopes,
|
||||||
|
});
|
||||||
|
|
||||||
@action
|
@action
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
|
|
@ -36,16 +59,191 @@ abstract class UserStoreBase with Store {
|
||||||
twitchApi
|
twitchApi
|
||||||
.getUserBlockedList(id: _details!.id)
|
.getUserBlockedList(id: _details!.id)
|
||||||
.then((blockedUsers) => _blockedUsers = blockedUsers.asObservable());
|
.then((blockedUsers) => _blockedUsers = blockedUsers.asObservable());
|
||||||
twitchApi
|
// Never hit moderated-channels Helix without moderator scopes — that 401
|
||||||
.getModeratedChannels(id: _details!.id)
|
// is what nags non-mod users after an app upgrade.
|
||||||
.then((channels) => _moderatedChannels = channels.asObservable());
|
if (hasModeratorScopes()) {
|
||||||
|
twitchApi
|
||||||
|
.getModeratedChannels(id: _details!.id)
|
||||||
|
.then(
|
||||||
|
(channels) => _moderatedChannels = channels.asObservable(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_moderatedChannels = ObservableList<String>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_disposeReaction?.call();
|
||||||
_disposeReaction = autorun(
|
_disposeReaction = autorun(
|
||||||
(_) => _blockedUsers.sort((a, b) => a.userLogin.compareTo(b.userLogin)),
|
(_) => _blockedUsers.sort((a, b) => a.userLogin.compareTo(b.userLogin)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether we already know follow state for [channelId] this session.
|
||||||
|
bool? cachedIsFollowing(String channelId) => _followCache[channelId];
|
||||||
|
|
||||||
|
@action
|
||||||
|
Future<bool> isFollowingChannel(String channelId) async {
|
||||||
|
final cached = _followCache[channelId];
|
||||||
|
if (cached != null) return cached;
|
||||||
|
|
||||||
|
final userId = _details?.id;
|
||||||
|
if (userId == null) return false;
|
||||||
|
|
||||||
|
final following = await twitchApi.isFollowingChannel(
|
||||||
|
userId: userId,
|
||||||
|
broadcasterId: channelId,
|
||||||
|
);
|
||||||
|
_followCache[channelId] = following;
|
||||||
|
return following;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Follows [targetId] via web GQL. [gqlToken] is the Twitch web cookie token.
|
||||||
|
@action
|
||||||
|
Future<void> follow({
|
||||||
|
required String targetId,
|
||||||
|
required String gqlToken,
|
||||||
|
}) async {
|
||||||
|
await _followMutation(
|
||||||
|
targetId: targetId,
|
||||||
|
gqlToken: gqlToken,
|
||||||
|
follow: true,
|
||||||
|
);
|
||||||
|
_followCache[targetId] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unfollows [targetId] via web GQL.
|
||||||
|
@action
|
||||||
|
Future<void> unfollow({
|
||||||
|
required String targetId,
|
||||||
|
required String gqlToken,
|
||||||
|
}) async {
|
||||||
|
await _followMutation(
|
||||||
|
targetId: targetId,
|
||||||
|
gqlToken: gqlToken,
|
||||||
|
follow: false,
|
||||||
|
);
|
||||||
|
_followCache[targetId] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _followMutation({
|
||||||
|
required String targetId,
|
||||||
|
required String gqlToken,
|
||||||
|
required bool follow,
|
||||||
|
}) async {
|
||||||
|
await runWithGqlIntegrity(
|
||||||
|
gqlToken: gqlToken,
|
||||||
|
action: ({authToken, integrityToken, deviceId}) {
|
||||||
|
if (follow) {
|
||||||
|
return twitchGqlApi.followUser(
|
||||||
|
targetId: targetId,
|
||||||
|
authToken: authToken!,
|
||||||
|
integrityToken: integrityToken,
|
||||||
|
deviceId: deviceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return twitchGqlApi.unfollowUser(
|
||||||
|
targetId: targetId,
|
||||||
|
authToken: authToken!,
|
||||||
|
integrityToken: integrityToken,
|
||||||
|
deviceId: deviceId,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs a GQL call with optional Client-Integrity; refreshes once on
|
||||||
|
/// [IntegrityCheckException] when a web [gqlToken] is available.
|
||||||
|
///
|
||||||
|
/// Dio-minted tokens that are `is_bad_bot` are discarded; callers should
|
||||||
|
/// then capture credentials via a WebView and [applyWebIntegrity].
|
||||||
|
Future<T> runWithGqlIntegrity<T>({
|
||||||
|
required String? gqlToken,
|
||||||
|
required Future<T> Function({
|
||||||
|
required String? authToken,
|
||||||
|
required String? integrityToken,
|
||||||
|
required String? deviceId,
|
||||||
|
}) action,
|
||||||
|
}) async {
|
||||||
|
final deviceId = await _ensureDeviceId();
|
||||||
|
final authToken =
|
||||||
|
gqlToken != null && gqlToken.isNotEmpty ? gqlToken : null;
|
||||||
|
|
||||||
|
Future<T> run(String? integrityToken) => action(
|
||||||
|
authToken: authToken,
|
||||||
|
integrityToken: integrityToken,
|
||||||
|
deviceId: _deviceId ?? deviceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await run(_validIntegrityToken());
|
||||||
|
} on IntegrityCheckException {
|
||||||
|
if (authToken == null) {
|
||||||
|
clearIntegrity();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final integrity = await twitchGqlApi.fetchIntegrityToken(
|
||||||
|
authToken: authToken,
|
||||||
|
deviceId: _deviceId ?? deviceId,
|
||||||
|
);
|
||||||
|
_integrityToken = integrity.token;
|
||||||
|
_integrityExpirationMs = integrity.expirationMs;
|
||||||
|
return await run(integrity.token);
|
||||||
|
} on IntegrityCheckException {
|
||||||
|
clearIntegrity();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores WebView-captured integrity (must match [GqlIntegrityCredentials.deviceId]).
|
||||||
|
Future<void> applyWebIntegrity(GqlIntegrityCredentials credentials) async {
|
||||||
|
if (TwitchIntegrity.isBadBot(credentials.integrityToken)) {
|
||||||
|
clearIntegrity();
|
||||||
|
throw const IntegrityCheckException(
|
||||||
|
'Twitch integrity token flagged as bad bot',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_integrityToken = credentials.integrityToken;
|
||||||
|
_integrityExpirationMs = credentials.expirationMs;
|
||||||
|
_deviceId = credentials.deviceId;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_deviceIdKey, credentials.deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearIntegrity() {
|
||||||
|
_integrityToken = null;
|
||||||
|
_integrityExpirationMs = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validIntegrityToken() {
|
||||||
|
if (_integrityToken == null) return null;
|
||||||
|
if (TwitchIntegrity.isBadBot(_integrityToken!)) {
|
||||||
|
clearIntegrity();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (DateTime.now().millisecondsSinceEpoch >= _integrityExpirationMs - 60000) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return _integrityToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _ensureDeviceId() async {
|
||||||
|
if (_deviceId != null) return _deviceId!;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
var id = prefs.getString(_deviceIdKey);
|
||||||
|
if (id == null || id.isEmpty) {
|
||||||
|
final random = Random.secure();
|
||||||
|
id = List.generate(
|
||||||
|
32,
|
||||||
|
(_) => random.nextInt(16).toRadixString(16),
|
||||||
|
).join();
|
||||||
|
await prefs.setString(_deviceIdKey, id);
|
||||||
|
}
|
||||||
|
_deviceId = id;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
Future<void> block({
|
Future<void> block({
|
||||||
required String targetId,
|
required String targetId,
|
||||||
|
|
@ -73,7 +271,7 @@ abstract class UserStoreBase with Store {
|
||||||
/// status granted or revoked since then.
|
/// status granted or revoked since then.
|
||||||
@action
|
@action
|
||||||
Future<void> refreshModeratedChannels() async {
|
Future<void> refreshModeratedChannels() async {
|
||||||
if (_details?.id == null) return;
|
if (_details?.id == null || !hasModeratorScopes()) return;
|
||||||
_moderatedChannels =
|
_moderatedChannels =
|
||||||
(await twitchApi.getModeratedChannels(id: _details!.id)).asObservable();
|
(await twitchApi.getModeratedChannels(id: _details!.id)).asObservable();
|
||||||
}
|
}
|
||||||
|
|
@ -85,18 +283,24 @@ abstract class UserStoreBase with Store {
|
||||||
/// Whether the logged-in user can moderate [channelId] — either a moderator
|
/// Whether the logged-in user can moderate [channelId] — either a moderator
|
||||||
/// of that channel or its broadcaster (the broadcaster isn't in their own
|
/// of that channel or its broadcaster (the broadcaster isn't in their own
|
||||||
/// moderator list but can still use the moderation endpoints).
|
/// moderator list but can still use the moderation endpoints).
|
||||||
|
///
|
||||||
|
/// Role only — does not require moderator OAuth scopes. Use
|
||||||
|
/// [canPerformModeratorActions] before calling Helix.
|
||||||
bool canModerate(String channelId) =>
|
bool canModerate(String channelId) =>
|
||||||
_details?.id == channelId || isModerator(channelId);
|
_details?.id == channelId || isModerator(channelId);
|
||||||
|
|
||||||
|
/// Role + moderator OAuth scopes — safe to call Helix mod endpoints.
|
||||||
|
bool canPerformModeratorActions(String channelId) =>
|
||||||
|
_details?.id != null &&
|
||||||
|
hasModeratorScopes() &&
|
||||||
|
canModerate(channelId);
|
||||||
|
|
||||||
@action
|
@action
|
||||||
Future<bool> deleteMessage({
|
Future<bool> deleteMessage({
|
||||||
required String broadcasterId,
|
required String broadcasterId,
|
||||||
required String messageId,
|
required String messageId,
|
||||||
}) async {
|
}) async {
|
||||||
// Need the moderator ID and confirmed mod status for this channel.
|
if (!canPerformModeratorActions(broadcasterId)) return false;
|
||||||
if (_details?.id == null || !canModerate(broadcasterId)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return twitchApi.deleteChatMessage(
|
return twitchApi.deleteChatMessage(
|
||||||
broadcasterId: broadcasterId,
|
broadcasterId: broadcasterId,
|
||||||
moderatorId: _details!.id,
|
moderatorId: _details!.id,
|
||||||
|
|
@ -111,10 +315,7 @@ abstract class UserStoreBase with Store {
|
||||||
int? duration,
|
int? duration,
|
||||||
String? reason,
|
String? reason,
|
||||||
}) async {
|
}) async {
|
||||||
// Need the moderator ID and confirmed mod status for this channel.
|
if (!canPerformModeratorActions(broadcasterId)) return false;
|
||||||
if (_details?.id == null || !canModerate(broadcasterId)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return twitchApi.banUser(
|
return twitchApi.banUser(
|
||||||
broadcasterId: broadcasterId,
|
broadcasterId: broadcasterId,
|
||||||
moderatorId: _details!.id,
|
moderatorId: _details!.id,
|
||||||
|
|
@ -130,9 +331,7 @@ abstract class UserStoreBase with Store {
|
||||||
required String broadcasterId,
|
required String broadcasterId,
|
||||||
required String userIdToUnban,
|
required String userIdToUnban,
|
||||||
}) async {
|
}) async {
|
||||||
if (_details?.id == null || !canModerate(broadcasterId)) {
|
if (!canPerformModeratorActions(broadcasterId)) return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return twitchApi.unbanUser(
|
return twitchApi.unbanUser(
|
||||||
broadcasterId: broadcasterId,
|
broadcasterId: broadcasterId,
|
||||||
moderatorId: _details!.id,
|
moderatorId: _details!.id,
|
||||||
|
|
@ -145,6 +344,9 @@ abstract class UserStoreBase with Store {
|
||||||
_details = null;
|
_details = null;
|
||||||
_blockedUsers.clear();
|
_blockedUsers.clear();
|
||||||
_moderatedChannels.clear();
|
_moderatedChannels.clear();
|
||||||
if (_disposeReaction != null) _disposeReaction!();
|
_followCache.clear();
|
||||||
|
clearIntegrity();
|
||||||
|
_disposeReaction?.call();
|
||||||
|
_disposeReaction = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,42 @@ mixin _$UserStore on UserStoreBase, Store {
|
||||||
return _$initAsyncAction.run(() => super.init());
|
return _$initAsyncAction.run(() => super.init());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
late final _$isFollowingChannelAsyncAction = AsyncAction(
|
||||||
|
'UserStoreBase.isFollowingChannel',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isFollowingChannel(String channelId) {
|
||||||
|
return _$isFollowingChannelAsyncAction.run(
|
||||||
|
() => super.isFollowingChannel(channelId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$followAsyncAction = AsyncAction(
|
||||||
|
'UserStoreBase.follow',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> follow({required String targetId, required String gqlToken}) {
|
||||||
|
return _$followAsyncAction.run(
|
||||||
|
() => super.follow(targetId: targetId, gqlToken: gqlToken),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$unfollowAsyncAction = AsyncAction(
|
||||||
|
'UserStoreBase.unfollow',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> unfollow({required String targetId, required String gqlToken}) {
|
||||||
|
return _$unfollowAsyncAction.run(
|
||||||
|
() => super.unfollow(targetId: targetId, gqlToken: gqlToken),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
late final _$blockAsyncAction = AsyncAction(
|
late final _$blockAsyncAction = AsyncAction(
|
||||||
'UserStoreBase.block',
|
'UserStoreBase.block',
|
||||||
context: context,
|
context: context,
|
||||||
|
|
|
||||||
|
|
@ -26,14 +26,29 @@ class VideoSettings extends StatelessWidget {
|
||||||
),
|
),
|
||||||
if (settingsStore.showVideo)
|
if (settingsStore.showVideo)
|
||||||
SettingsListSwitch(
|
SettingsListSwitch(
|
||||||
title: 'Native player (experimental)',
|
title: 'Native player',
|
||||||
subtitle: const Text(
|
subtitle: const Text(
|
||||||
'More performant video player with auto picture-in-picture and quality selection.',
|
'Picture-in-Picture, quality selection, and lower latency. Turn off to use the legacy WebView player.',
|
||||||
),
|
),
|
||||||
value: settingsStore.useNativePlayer,
|
value: settingsStore.useNativePlayer,
|
||||||
onChanged: (newValue) =>
|
onChanged: (newValue) =>
|
||||||
settingsStore.useNativePlayer = newValue,
|
settingsStore.useNativePlayer = newValue,
|
||||||
),
|
),
|
||||||
|
if (settingsStore.showVideo)
|
||||||
|
SettingsListSwitch(
|
||||||
|
title: 'Block Twitch ads',
|
||||||
|
subtitle: Text(
|
||||||
|
settingsStore.useNativePlayer
|
||||||
|
? 'Uses TwitchAdSolutions (vaft): popout/embed/autoplay player types and backup streams during ad breaks.'
|
||||||
|
: 'Injects the vaft userscript into the WebView. Native player is more reliable — enable it above if ads still play.',
|
||||||
|
),
|
||||||
|
value: settingsStore.blockTwitchAds,
|
||||||
|
onChanged: (newValue) {
|
||||||
|
settingsStore.blockTwitchAds = newValue;
|
||||||
|
// Ad blocking is most effective on the native path.
|
||||||
|
if (newValue) settingsStore.useNativePlayer = true;
|
||||||
|
},
|
||||||
|
),
|
||||||
if (!Platform.isIOS || isIPad())
|
if (!Platform.isIOS || isIPad())
|
||||||
SettingsListSwitch(
|
SettingsListSwitch(
|
||||||
title: 'Default to highest quality',
|
title: 'Default to highest quality',
|
||||||
|
|
|
||||||
94
lib/utils/twitch_oauth_scopes.dart
Normal file
94
lib/utils/twitch_oauth_scopes.dart
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
/// Core OAuth scopes Frosty requests on a normal Twitch login.
|
||||||
|
///
|
||||||
|
/// Matches the pre-moderator-tools set so existing tokens remain valid without
|
||||||
|
/// a re-consent nag for non-moderators.
|
||||||
|
const twitchUserScopes = <String>[
|
||||||
|
'chat:read',
|
||||||
|
'chat:edit',
|
||||||
|
'user:read:follows',
|
||||||
|
'user:read:blocked_users',
|
||||||
|
'user:manage:blocked_users',
|
||||||
|
'user:manage:chat_color',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Moderator-only scopes requested via an explicit opt-in upgrade.
|
||||||
|
const twitchModeratorScopes = <String>[
|
||||||
|
'user:read:moderated_channels',
|
||||||
|
'moderator:manage:chat_messages',
|
||||||
|
'moderator:manage:banned_users',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Core + moderator scopes for an opted-in moderator session.
|
||||||
|
const twitchAllUserScopes = <String>[
|
||||||
|
...twitchUserScopes,
|
||||||
|
...twitchModeratorScopes,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Space-separated scope string for Twitch's authorize URL (core only).
|
||||||
|
String get twitchUserScopeQuery => twitchUserScopes.join(' ');
|
||||||
|
|
||||||
|
/// Space-separated scope string for Twitch's authorize URL (core + mod).
|
||||||
|
String get twitchAllUserScopeQuery => twitchAllUserScopes.join(' ');
|
||||||
|
|
||||||
|
/// Human-readable labels for Frosty-requested scopes.
|
||||||
|
const twitchScopeLabels = <String, String>{
|
||||||
|
'chat:read': 'Read chat messages',
|
||||||
|
'chat:edit': 'Send chat messages',
|
||||||
|
'user:read:follows': 'View followed channels',
|
||||||
|
'user:read:blocked_users': 'View blocked users',
|
||||||
|
'user:manage:blocked_users': 'Block and unblock users',
|
||||||
|
'user:manage:chat_color': 'Change chat name color',
|
||||||
|
'user:read:moderated_channels': 'View channels you moderate',
|
||||||
|
'moderator:manage:chat_messages': 'Delete chat messages as a moderator',
|
||||||
|
'moderator:manage:banned_users': 'Timeout and ban users as a moderator',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Whether [granted] includes every moderator scope Frosty needs.
|
||||||
|
bool hasModeratorScopes(Iterable<String> granted) =>
|
||||||
|
missingTwitchScopes(
|
||||||
|
granted: granted,
|
||||||
|
required: twitchModeratorScopes,
|
||||||
|
).isEmpty;
|
||||||
|
|
||||||
|
/// Scopes in [required] that are not present in [granted].
|
||||||
|
List<String> missingTwitchScopes({
|
||||||
|
required Iterable<String> granted,
|
||||||
|
Iterable<String> required = twitchUserScopes,
|
||||||
|
}) {
|
||||||
|
final grantedSet = granted.toSet();
|
||||||
|
return required.where((scope) => !grantedSet.contains(scope)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _scopeBullets(Iterable<String> scopes) => scopes
|
||||||
|
.map((scope) => '• ${twitchScopeLabels[scope] ?? scope}')
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
/// Dialog body for a Twitch Helix/OAuth 401 on a core (non-mod) request.
|
||||||
|
///
|
||||||
|
/// Missing-permissions copy only lists **core** scopes. A token that already
|
||||||
|
/// has core scopes is treated as expired/invalid rather than "missing mod
|
||||||
|
/// permissions" so non-mods are never nagged about moderator consent.
|
||||||
|
String unauthorizedDialogMessage({
|
||||||
|
required bool isLoggedIn,
|
||||||
|
required Iterable<String> grantedScopes,
|
||||||
|
}) {
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return 'Your session has expired. Please log in again to continue.';
|
||||||
|
}
|
||||||
|
|
||||||
|
final missing = missingTwitchScopes(granted: grantedScopes);
|
||||||
|
if (missing.isEmpty) {
|
||||||
|
return 'Your session has expired. Please log in again to continue.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Frosty needs additional Twitch permissions:\n\n'
|
||||||
|
'${_scopeBullets(missing)}\n\n'
|
||||||
|
'Log in again to grant them.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy for the explicit moderator-tools opt-in dialog.
|
||||||
|
String enableModeratorToolsDialogMessage() {
|
||||||
|
return 'Enable moderator tools to manage chat in channels you moderate:\n\n'
|
||||||
|
'${_scopeBullets(twitchModeratorScopes)}\n\n'
|
||||||
|
'You will be asked to authorize these permissions with Twitch.';
|
||||||
|
}
|
||||||
206
lib/widgets/follow_button.dart
Normal file
206
lib/widgets/follow_button.dart
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:frosty/apis/base_api_client.dart';
|
||||||
|
import 'package:frosty/screens/onboarding/login_webview.dart';
|
||||||
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
|
import 'package:frosty/widgets/alert_message.dart';
|
||||||
|
import 'package:frosty/widgets/follow_channel_controller.dart';
|
||||||
|
import 'package:frosty/widgets/frosty_dialog.dart';
|
||||||
|
import 'package:frosty/widgets/integrity_webview_dialog.dart';
|
||||||
|
|
||||||
|
/// Compact Follow / Unfollow control for channel headers.
|
||||||
|
class FollowButton extends StatefulWidget {
|
||||||
|
final AuthStore authStore;
|
||||||
|
final String userId;
|
||||||
|
final String displayName;
|
||||||
|
final bool? initialIsFollowing;
|
||||||
|
final ValueChanged<bool>? onFollowChanged;
|
||||||
|
|
||||||
|
const FollowButton({
|
||||||
|
super.key,
|
||||||
|
required this.authStore,
|
||||||
|
required this.userId,
|
||||||
|
required this.displayName,
|
||||||
|
this.initialIsFollowing,
|
||||||
|
this.onFollowChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FollowButton> createState() => _FollowButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FollowButtonState extends State<FollowButton> {
|
||||||
|
late final FollowChannelController _controller = FollowChannelController(
|
||||||
|
authStore: widget.authStore,
|
||||||
|
userId: widget.userId,
|
||||||
|
initialIsFollowing: widget.initialIsFollowing,
|
||||||
|
onChanged: () {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onPressed() async {
|
||||||
|
if (!_controller.canFollow ||
|
||||||
|
_controller.isFollowing == null ||
|
||||||
|
_controller.actionInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final wasFollowing = _controller.isFollowing!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final nowFollowing = await _controller.toggle();
|
||||||
|
if (!mounted) return;
|
||||||
|
widget.onFollowChanged?.call(nowFollowing);
|
||||||
|
_showResultSnack(nowFollowing);
|
||||||
|
} on FollowNeedsWebSessionException {
|
||||||
|
if (!mounted) return;
|
||||||
|
await _promptLinkWebSession();
|
||||||
|
} on IntegrityCheckException {
|
||||||
|
if (!mounted) return;
|
||||||
|
await _handleIntegrity(wasFollowing: wasFollowing);
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showErrorSnack(e.message);
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showErrorSnack(
|
||||||
|
wasFollowing
|
||||||
|
? 'Could not unfollow ${widget.displayName}'
|
||||||
|
: 'Could not follow ${widget.displayName}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleIntegrity({required bool wasFollowing}) async {
|
||||||
|
final gqlToken = widget.authStore.gqlToken;
|
||||||
|
if (gqlToken == null) {
|
||||||
|
_controller.clearActionInProgress();
|
||||||
|
await _promptLinkWebSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final credentials = await showIntegrityWebViewDialog(
|
||||||
|
context: context,
|
||||||
|
gqlToken: gqlToken,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (credentials == null) {
|
||||||
|
_controller.clearActionInProgress();
|
||||||
|
_showErrorSnack('Follow cancelled — Twitch verification required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final nowFollowing = await _controller.retryAfterIntegrity(
|
||||||
|
wasFollowing: wasFollowing,
|
||||||
|
credentials: credentials,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
widget.onFollowChanged?.call(nowFollowing);
|
||||||
|
_showResultSnack(nowFollowing);
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showErrorSnack(e.message);
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showErrorSnack(
|
||||||
|
wasFollowing
|
||||||
|
? 'Could not unfollow ${widget.displayName}'
|
||||||
|
: 'Could not follow ${widget.displayName}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _promptLinkWebSession() {
|
||||||
|
return showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => FrostyDialog(
|
||||||
|
title: 'Web session required',
|
||||||
|
message:
|
||||||
|
'Following channels needs your Twitch web session linked. Log in again to link it, then try follow/unfollow.',
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: Navigator.of(dialogContext).pop,
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (context) => LoginWebView()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Log in'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showResultSnack(bool nowFollowing) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: AlertMessage(
|
||||||
|
message: nowFollowing
|
||||||
|
? 'Followed ${widget.displayName}'
|
||||||
|
: 'Unfollowed ${widget.displayName}',
|
||||||
|
centered: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showErrorSnack(String message) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: AlertMessage(message: message, centered: false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!_controller.shouldShow) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final following = _controller.isFollowing == true;
|
||||||
|
final busy = _controller.loading || _controller.actionInProgress;
|
||||||
|
|
||||||
|
return FilledButton.tonal(
|
||||||
|
onPressed: busy ? null : _onPressed,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
minimumSize: const Size(0, 36),
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
child: busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
spacing: 6,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
following
|
||||||
|
? Icons.favorite_rounded
|
||||||
|
: Icons.favorite_border_rounded,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
Text(following ? 'Unfollow' : 'Follow'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
150
lib/widgets/follow_channel_controller.dart
Normal file
150
lib/widgets/follow_channel_controller.dart
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import 'package:frosty/apis/base_api_client.dart';
|
||||||
|
import 'package:frosty/apis/twitch_integrity.dart';
|
||||||
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
|
|
||||||
|
/// Shared follow/unfollow state + GQL mutations for channel UI.
|
||||||
|
///
|
||||||
|
/// Owns loading/action flags and the session cache via [AuthStore.user].
|
||||||
|
/// Callers handle UI (integrity WebView, login prompt, snackbars).
|
||||||
|
class FollowChannelController {
|
||||||
|
final AuthStore authStore;
|
||||||
|
final String userId;
|
||||||
|
final bool? initialIsFollowing;
|
||||||
|
final void Function() onChanged;
|
||||||
|
|
||||||
|
bool? isFollowing;
|
||||||
|
bool loading = false;
|
||||||
|
bool actionInProgress = false;
|
||||||
|
|
||||||
|
FollowChannelController({
|
||||||
|
required this.authStore,
|
||||||
|
required this.userId,
|
||||||
|
this.initialIsFollowing,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isSelf =>
|
||||||
|
authStore.user.details?.id != null &&
|
||||||
|
authStore.user.details!.id == userId;
|
||||||
|
|
||||||
|
bool get canFollow => authStore.isLoggedIn && !isSelf;
|
||||||
|
|
||||||
|
/// Whether the follow control should be visible (loading or known state).
|
||||||
|
bool get shouldShow => canFollow && (loading || isFollowing != null);
|
||||||
|
|
||||||
|
void init() {
|
||||||
|
if (!canFollow) return;
|
||||||
|
if (initialIsFollowing != null) {
|
||||||
|
isFollowing = initialIsFollowing;
|
||||||
|
onChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadFollowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadFollowState() async {
|
||||||
|
final cached = authStore.user.cachedIsFollowing(userId);
|
||||||
|
if (cached != null) {
|
||||||
|
isFollowing = cached;
|
||||||
|
onChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
onChanged();
|
||||||
|
try {
|
||||||
|
final following = await authStore.user.isFollowingChannel(userId);
|
||||||
|
isFollowing = following;
|
||||||
|
} catch (_) {
|
||||||
|
// Leave isFollowing null so the control stays hidden.
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
onChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggles follow. Returns the new following state on success.
|
||||||
|
///
|
||||||
|
/// Throws [IntegrityCheckException] when a WebView integrity mint is needed,
|
||||||
|
/// [ApiException] for API errors, or [StateError] when not ready / no token.
|
||||||
|
Future<bool> toggle() async {
|
||||||
|
if (actionInProgress || isFollowing == null) {
|
||||||
|
throw StateError('Follow toggle not ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
final gqlToken = authStore.gqlToken;
|
||||||
|
if (gqlToken == null) {
|
||||||
|
throw const FollowNeedsWebSessionException();
|
||||||
|
}
|
||||||
|
|
||||||
|
final following = isFollowing!;
|
||||||
|
actionInProgress = true;
|
||||||
|
onChanged();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _runMutation(following: following, gqlToken: gqlToken);
|
||||||
|
} catch (_) {
|
||||||
|
actionInProgress = false;
|
||||||
|
onChanged();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nowFollowing = !following;
|
||||||
|
isFollowing = nowFollowing;
|
||||||
|
actionInProgress = false;
|
||||||
|
onChanged();
|
||||||
|
return nowFollowing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After a WebView integrity mint, apply credentials and retry [toggle]'s
|
||||||
|
/// intended mutation (still based on the pre-toggle [wasFollowing] state).
|
||||||
|
Future<bool> retryAfterIntegrity({
|
||||||
|
required bool wasFollowing,
|
||||||
|
required GqlIntegrityCredentials credentials,
|
||||||
|
}) async {
|
||||||
|
final gqlToken = authStore.gqlToken;
|
||||||
|
if (gqlToken == null) {
|
||||||
|
throw const FollowNeedsWebSessionException();
|
||||||
|
}
|
||||||
|
|
||||||
|
actionInProgress = true;
|
||||||
|
onChanged();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authStore.user.applyWebIntegrity(credentials);
|
||||||
|
await _runMutation(following: wasFollowing, gqlToken: gqlToken);
|
||||||
|
} catch (_) {
|
||||||
|
actionInProgress = false;
|
||||||
|
onChanged();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nowFollowing = !wasFollowing;
|
||||||
|
isFollowing = nowFollowing;
|
||||||
|
actionInProgress = false;
|
||||||
|
onChanged();
|
||||||
|
return nowFollowing;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _runMutation({
|
||||||
|
required bool following,
|
||||||
|
required String gqlToken,
|
||||||
|
}) async {
|
||||||
|
if (following) {
|
||||||
|
await authStore.user.unfollow(targetId: userId, gqlToken: gqlToken);
|
||||||
|
} else {
|
||||||
|
await authStore.user.follow(targetId: userId, gqlToken: gqlToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearActionInProgress() {
|
||||||
|
if (!actionInProgress) return;
|
||||||
|
actionInProgress = false;
|
||||||
|
onChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thrown when follow needs a linked Twitch web (GQL) session.
|
||||||
|
class FollowNeedsWebSessionException implements Exception {
|
||||||
|
const FollowNeedsWebSessionException();
|
||||||
|
}
|
||||||
232
lib/widgets/integrity_webview_dialog.dart
Normal file
232
lib/widgets/integrity_webview_dialog.dart
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:frosty/apis/twitch_integrity.dart';
|
||||||
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
|
|
||||||
|
/// Captures a browser-valid `Client-Integrity` header by loading Twitch in a
|
||||||
|
/// WebView (Dio `/integrity` tokens are almost always `is_bad_bot`).
|
||||||
|
///
|
||||||
|
/// Returns credentials, or null if the user cancels / capture times out.
|
||||||
|
Future<GqlIntegrityCredentials?> showIntegrityWebViewDialog({
|
||||||
|
required BuildContext context,
|
||||||
|
required String gqlToken,
|
||||||
|
}) {
|
||||||
|
return showDialog<GqlIntegrityCredentials>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (dialogContext) => IntegrityWebViewDialog(gqlToken: gqlToken),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class IntegrityWebViewDialog extends StatefulWidget {
|
||||||
|
final String gqlToken;
|
||||||
|
|
||||||
|
const IntegrityWebViewDialog({super.key, required this.gqlToken});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<IntegrityWebViewDialog> createState() => _IntegrityWebViewDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _IntegrityWebViewDialogState extends State<IntegrityWebViewDialog> {
|
||||||
|
static const _captureTimeout = Duration(seconds: 45);
|
||||||
|
|
||||||
|
late final WebViewController _controller;
|
||||||
|
Timer? _timeout;
|
||||||
|
var _captured = false;
|
||||||
|
var _status = 'Verifying with Twitch…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = WebViewController()
|
||||||
|
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||||
|
..setUserAgent(
|
||||||
|
Platform.isIOS
|
||||||
|
? 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) '
|
||||||
|
'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 '
|
||||||
|
'Mobile/15E148 Safari/604.1'
|
||||||
|
: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 '
|
||||||
|
'(KHTML, like Gecko) Chrome/135.0.0.0 Mobile Safari/537.36',
|
||||||
|
)
|
||||||
|
..addJavaScriptChannel(
|
||||||
|
'FrostyIntegrity',
|
||||||
|
onMessageReceived: _onIntegrityMessage,
|
||||||
|
)
|
||||||
|
..setNavigationDelegate(
|
||||||
|
NavigationDelegate(
|
||||||
|
onPageStarted: (_) => _injectHooks(),
|
||||||
|
onPageFinished: (_) => _injectHooks(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
_prepareAndLoad();
|
||||||
|
_timeout = Timer(_captureTimeout, () {
|
||||||
|
if (!mounted || _captured) return;
|
||||||
|
setState(() => _status = 'Timed out waiting for Twitch. Try again.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _prepareAndLoad() async {
|
||||||
|
try {
|
||||||
|
final cookies = WebViewCookieManager();
|
||||||
|
await cookies.setCookie(
|
||||||
|
WebViewCookie(
|
||||||
|
name: 'auth-token',
|
||||||
|
value: widget.gqlToken,
|
||||||
|
domain: '.twitch.tv',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('IntegrityWebView: cookie set failed: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same entry as Xtra: with auth-token set, Twitch redirects and emits GQL
|
||||||
|
// requests that carry a browser-valid Client-Integrity header.
|
||||||
|
await _controller.loadRequest(Uri.parse('https://www.twitch.tv/login'));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _injectHooks() async {
|
||||||
|
// Hook fetch + XHR so we see Client-Integrity on Twitch's own GQL traffic.
|
||||||
|
const script = r'''
|
||||||
|
(function () {
|
||||||
|
if (window.__frostyIntegrityHooked) return;
|
||||||
|
window.__frostyIntegrityHooked = true;
|
||||||
|
|
||||||
|
function report(headers) {
|
||||||
|
if (!headers || !window.FrostyIntegrity) return;
|
||||||
|
var integrity = headers['Client-Integrity'] || headers['client-integrity'];
|
||||||
|
if (!integrity) return;
|
||||||
|
var deviceId = headers['X-Device-Id'] || headers['x-device-id'] || '';
|
||||||
|
var clientId = headers['Client-ID'] || headers['Client-Id'] || headers['client-id'] || '';
|
||||||
|
window.FrostyIntegrity.postMessage(JSON.stringify({
|
||||||
|
integrity: integrity,
|
||||||
|
deviceId: deviceId,
|
||||||
|
clientId: clientId
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function headersToObject(headers) {
|
||||||
|
var out = {};
|
||||||
|
if (!headers) return out;
|
||||||
|
if (typeof Headers !== 'undefined' && headers instanceof Headers) {
|
||||||
|
headers.forEach(function (value, key) { out[key] = value; });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (typeof headers === 'object') {
|
||||||
|
Object.keys(headers).forEach(function (key) { out[key] = headers[key]; });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
var originalFetch = window.fetch;
|
||||||
|
window.fetch = function (input, init) {
|
||||||
|
try {
|
||||||
|
report(headersToObject(init && init.headers));
|
||||||
|
} catch (e) {}
|
||||||
|
return originalFetch.apply(this, arguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
var originalOpen = XMLHttpRequest.prototype.open;
|
||||||
|
var originalSetHeader = XMLHttpRequest.prototype.setRequestHeader;
|
||||||
|
var originalSend = XMLHttpRequest.prototype.send;
|
||||||
|
XMLHttpRequest.prototype.open = function () {
|
||||||
|
this.__frostyHeaders = {};
|
||||||
|
return originalOpen.apply(this, arguments);
|
||||||
|
};
|
||||||
|
XMLHttpRequest.prototype.setRequestHeader = function (key, value) {
|
||||||
|
try { this.__frostyHeaders[key] = value; } catch (e) {}
|
||||||
|
return originalSetHeader.apply(this, arguments);
|
||||||
|
};
|
||||||
|
XMLHttpRequest.prototype.send = function () {
|
||||||
|
try { report(this.__frostyHeaders || {}); } catch (e) {}
|
||||||
|
return originalSend.apply(this, arguments);
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
''';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _controller.runJavaScript(script);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('IntegrityWebView: hook inject failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onIntegrityMessage(JavaScriptMessage message) {
|
||||||
|
if (_captured || !mounted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final map = jsonDecode(message.message) as Map<String, dynamic>;
|
||||||
|
final integrity = map['integrity'] as String?;
|
||||||
|
if (integrity == null || integrity.isEmpty) return;
|
||||||
|
if (TwitchIntegrity.isBadBot(integrity)) {
|
||||||
|
debugPrint('IntegrityWebView: ignoring bad_bot token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final fromHeader = map['deviceId'] as String?;
|
||||||
|
final fromToken = TwitchIntegrity.deviceId(integrity);
|
||||||
|
final deviceId = (fromHeader != null && fromHeader.isNotEmpty)
|
||||||
|
? fromHeader
|
||||||
|
: (fromToken ?? '');
|
||||||
|
if (deviceId.isEmpty) {
|
||||||
|
debugPrint('IntegrityWebView: missing device id');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final expiration = TwitchIntegrity.expirationMs(integrity) ??
|
||||||
|
DateTime.now().millisecondsSinceEpoch + const Duration(hours: 16).inMilliseconds;
|
||||||
|
|
||||||
|
_captured = true;
|
||||||
|
_timeout?.cancel();
|
||||||
|
Navigator.of(context).pop(
|
||||||
|
GqlIntegrityCredentials(
|
||||||
|
integrityToken: integrity,
|
||||||
|
deviceId: deviceId,
|
||||||
|
expirationMs: expiration,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('IntegrityWebView: parse failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_timeout?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final height = MediaQuery.sizeOf(context).height * 0.55;
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Confirm with Twitch'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: double.maxFinite,
|
||||||
|
height: height.clamp(280.0, 520.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(_status),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Expanded(
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: WebViewWidget(controller: _controller),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
lib/widgets/stream_sort_button.dart
Normal file
38
lib/widgets/stream_sort_button.dart
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||||
|
|
||||||
|
/// Toggles viewer-count sort between descending (default) and ascending.
|
||||||
|
class StreamSortButton extends StatelessWidget {
|
||||||
|
final StreamViewerSort sortOrder;
|
||||||
|
final ValueChanged<StreamViewerSort> onChanged;
|
||||||
|
|
||||||
|
const StreamSortButton({
|
||||||
|
super.key,
|
||||||
|
required this.sortOrder,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isAsc = sortOrder == StreamViewerSort.viewersAsc;
|
||||||
|
|
||||||
|
return PopupMenuButton<StreamViewerSort>(
|
||||||
|
tooltip: 'Sort by viewers',
|
||||||
|
icon: Icon(
|
||||||
|
isAsc ? Icons.arrow_upward_rounded : Icons.arrow_downward_rounded,
|
||||||
|
),
|
||||||
|
initialValue: sortOrder,
|
||||||
|
onSelected: onChanged,
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: StreamViewerSort.viewersDesc,
|
||||||
|
child: Text('Most viewers'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: StreamViewerSort.viewersAsc,
|
||||||
|
child: Text('Fewest viewers'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:frosty/apis/base_api_client.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/screens/settings/stores/settings_store.dart';
|
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||||
|
import 'package:frosty/widgets/alert_message.dart';
|
||||||
import 'package:frosty/widgets/blurred_container.dart';
|
import 'package:frosty/widgets/blurred_container.dart';
|
||||||
|
import 'package:frosty/widgets/follow_channel_controller.dart';
|
||||||
|
import 'package:frosty/widgets/frosty_dialog.dart';
|
||||||
|
import 'package:frosty/widgets/integrity_webview_dialog.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:webview_flutter/webview_flutter.dart';
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
|
|
||||||
class UserActionsModal extends StatelessWidget {
|
class UserActionsModal extends StatefulWidget {
|
||||||
final AuthStore authStore;
|
final AuthStore authStore;
|
||||||
final String name;
|
final String name;
|
||||||
final String userLogin;
|
final String userLogin;
|
||||||
|
|
@ -14,6 +20,12 @@ class UserActionsModal extends StatelessWidget {
|
||||||
final bool showPinOption;
|
final bool showPinOption;
|
||||||
final bool? isPinned;
|
final bool? isPinned;
|
||||||
|
|
||||||
|
/// When set (e.g. Following tab), skip the Helix lookup and show this state.
|
||||||
|
final bool? initialIsFollowing;
|
||||||
|
|
||||||
|
/// Called after a successful follow (`true`) or unfollow (`false`).
|
||||||
|
final ValueChanged<bool>? onFollowChanged;
|
||||||
|
|
||||||
/// Optional moderation actions (shown at the top) for chat contexts. Kept as
|
/// Optional moderation actions (shown at the top) for chat contexts. Kept as
|
||||||
/// an injected widget so this generic modal stays chat-agnostic.
|
/// an injected widget so this generic modal stays chat-agnostic.
|
||||||
final Widget? moderationActions;
|
final Widget? moderationActions;
|
||||||
|
|
@ -26,51 +38,224 @@ class UserActionsModal extends StatelessWidget {
|
||||||
required this.userId,
|
required this.userId,
|
||||||
this.showPinOption = false,
|
this.showPinOption = false,
|
||||||
this.isPinned,
|
this.isPinned,
|
||||||
|
this.initialIsFollowing,
|
||||||
|
this.onFollowChanged,
|
||||||
this.moderationActions,
|
this.moderationActions,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<UserActionsModal> createState() => _UserActionsModalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UserActionsModalState extends State<UserActionsModal> {
|
||||||
|
late final FollowChannelController _follow = FollowChannelController(
|
||||||
|
authStore: widget.authStore,
|
||||||
|
userId: widget.userId,
|
||||||
|
initialIsFollowing: widget.initialIsFollowing,
|
||||||
|
onChanged: () {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_follow.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleFollow() async {
|
||||||
|
if (!_follow.canFollow ||
|
||||||
|
_follow.isFollowing == null ||
|
||||||
|
_follow.actionInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final wasFollowing = _follow.isFollowing!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final nowFollowing = await _follow.toggle();
|
||||||
|
if (!mounted) return;
|
||||||
|
widget.onFollowChanged?.call(nowFollowing);
|
||||||
|
_popWithSnack(
|
||||||
|
nowFollowing
|
||||||
|
? 'Followed ${widget.name}'
|
||||||
|
: 'Unfollowed ${widget.name}',
|
||||||
|
);
|
||||||
|
} on FollowNeedsWebSessionException {
|
||||||
|
if (!mounted) return;
|
||||||
|
await _promptLinkWebSession();
|
||||||
|
} on IntegrityCheckException {
|
||||||
|
if (!mounted) return;
|
||||||
|
await _handleIntegrity(wasFollowing: wasFollowing);
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showErrorSnack(e.message);
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showErrorSnack(
|
||||||
|
wasFollowing
|
||||||
|
? 'Could not unfollow ${widget.name}'
|
||||||
|
: 'Could not follow ${widget.name}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleIntegrity({required bool wasFollowing}) async {
|
||||||
|
final gqlToken = widget.authStore.gqlToken;
|
||||||
|
if (gqlToken == null) {
|
||||||
|
_follow.clearActionInProgress();
|
||||||
|
await _promptLinkWebSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final credentials = await showIntegrityWebViewDialog(
|
||||||
|
context: context,
|
||||||
|
gqlToken: gqlToken,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (credentials == null) {
|
||||||
|
_follow.clearActionInProgress();
|
||||||
|
_showErrorSnack('Follow cancelled — Twitch verification required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final nowFollowing = await _follow.retryAfterIntegrity(
|
||||||
|
wasFollowing: wasFollowing,
|
||||||
|
credentials: credentials,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
widget.onFollowChanged?.call(nowFollowing);
|
||||||
|
_popWithSnack(
|
||||||
|
nowFollowing
|
||||||
|
? 'Followed ${widget.name}'
|
||||||
|
: 'Unfollowed ${widget.name}',
|
||||||
|
);
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showErrorSnack(e.message);
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showErrorSnack(
|
||||||
|
wasFollowing
|
||||||
|
? 'Could not unfollow ${widget.name}'
|
||||||
|
: 'Could not follow ${widget.name}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _popWithSnack(String message) {
|
||||||
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||||
|
Navigator.pop(context);
|
||||||
|
messenger?.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: AlertMessage(message: message, centered: false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showErrorSnack(String message) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: AlertMessage(message: message, centered: false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _promptLinkWebSession() {
|
||||||
|
return showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => FrostyDialog(
|
||||||
|
title: 'Web session required',
|
||||||
|
message:
|
||||||
|
'Following channels needs your Twitch web session linked. Log in again to link it, then try follow/unfollow.',
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: Navigator.of(dialogContext).pop,
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (context) => LoginWebView()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Log in'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final showFollowTile = _follow.shouldShow;
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
primary: false,
|
primary: false,
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
children: [
|
children: [
|
||||||
?moderationActions,
|
?widget.moderationActions,
|
||||||
if (showPinOption)
|
if (widget.showPinOption)
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.push_pin_outlined),
|
leading: const Icon(Icons.push_pin_outlined),
|
||||||
title: Text('${isPinned == true ? 'Unpin' : 'Pin'} $name'),
|
title: Text(
|
||||||
|
'${widget.isPinned == true ? 'Unpin' : 'Pin'} ${widget.name}',
|
||||||
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (isPinned == true) {
|
if (widget.isPinned == true) {
|
||||||
context.read<SettingsStore>().pinnedChannelIds = [
|
context.read<SettingsStore>().pinnedChannelIds = [
|
||||||
...context.read<SettingsStore>().pinnedChannelIds
|
...context.read<SettingsStore>().pinnedChannelIds
|
||||||
..remove(userId),
|
..remove(widget.userId),
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
context.read<SettingsStore>().pinnedChannelIds = [
|
context.read<SettingsStore>().pinnedChannelIds = [
|
||||||
...context.read<SettingsStore>().pinnedChannelIds,
|
...context.read<SettingsStore>().pinnedChannelIds,
|
||||||
userId,
|
widget.userId,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (authStore.isLoggedIn)
|
if (showFollowTile)
|
||||||
|
ListTile(
|
||||||
|
leading: _follow.loading || _follow.actionInProgress
|
||||||
|
? const SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
_follow.isFollowing == true
|
||||||
|
? Icons.heart_broken_rounded
|
||||||
|
: Icons.favorite_rounded,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
_follow.isFollowing == true
|
||||||
|
? 'Unfollow ${widget.name}'
|
||||||
|
: 'Follow ${widget.name}',
|
||||||
|
),
|
||||||
|
enabled: !_follow.loading && !_follow.actionInProgress,
|
||||||
|
onTap: _toggleFollow,
|
||||||
|
),
|
||||||
|
if (widget.authStore.isLoggedIn)
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.block_rounded),
|
leading: const Icon(Icons.block_rounded),
|
||||||
onTap: () => authStore
|
onTap: () => widget.authStore
|
||||||
.showBlockDialog(
|
.showBlockDialog(
|
||||||
context,
|
context,
|
||||||
targetUser: name,
|
targetUser: widget.name,
|
||||||
targetUserId: userId,
|
targetUserId: widget.userId,
|
||||||
)
|
)
|
||||||
.then((_) {
|
.then((_) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
title: Text('Block $name'),
|
title: Text('Block ${widget.name}'),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.outlined_flag_rounded),
|
leading: const Icon(Icons.outlined_flag_rounded),
|
||||||
|
|
@ -101,7 +286,7 @@ class UserActionsModal extends StatelessWidget {
|
||||||
icon: Icon(Icons.adaptive.arrow_back_rounded),
|
icon: Icon(Icons.adaptive.arrow_back_rounded),
|
||||||
onPressed: Navigator.of(context).pop,
|
onPressed: Navigator.of(context).pop,
|
||||||
),
|
),
|
||||||
title: Text('Report $name'),
|
title: Text('Report ${widget.name}'),
|
||||||
),
|
),
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -118,7 +303,7 @@ class UserActionsModal extends StatelessWidget {
|
||||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||||
..loadRequest(
|
..loadRequest(
|
||||||
Uri.parse(
|
Uri.parse(
|
||||||
'https://www.twitch.tv/$userLogin/report',
|
'https://www.twitch.tv/${widget.userLogin}/report',
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
..setNavigationDelegate(
|
..setNavigationDelegate(
|
||||||
|
|
@ -154,7 +339,7 @@ class UserActionsModal extends StatelessWidget {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text('Report $name'),
|
title: Text('Report ${widget.name}'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
208
pubspec.lock
208
pubspec.lock
|
|
@ -13,10 +13,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: _flutterfire_internals
|
name: _flutterfire_internals
|
||||||
sha256: "0d1f0adfabbab9f46a1a80ce84a4d8b852b6e4dbf53ce413b30e0cf7d3631b71"
|
sha256: "6727cf2ced9b104abca9daa278380be2eca2b98ce33d4b46f11708e387dc6b4d"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.72"
|
version: "1.3.76"
|
||||||
advanced_in_app_review:
|
advanced_in_app_review:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -45,10 +45,10 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: app_links
|
name: app_links
|
||||||
sha256: "4ec328cd9fd51fd0e7eb8870a9612c1fde0f091901932238c4f4e60b5f7f1315"
|
sha256: f8db46d2ea9ff6f3a37191a7fd5b7813da1253ace8c32c1b9eadace41d1188ea
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.1.2"
|
version: "7.2.1"
|
||||||
app_links_linux:
|
app_links_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -61,10 +61,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: app_links_platform_interface
|
name: app_links_platform_interface
|
||||||
sha256: "78a18580eecac98108d1eef52a7db668bc317714f5205e616973363326efe333"
|
sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.3"
|
version: "2.0.4"
|
||||||
app_links_web:
|
app_links_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -102,7 +102,7 @@ packages:
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: frosty-patches
|
ref: frosty-patches
|
||||||
resolved-ref: "6460859318b7b44034fe44016cddff02d0bd4c0a"
|
resolved-ref: "19c627547c227f1fc9895a5a62839e64fec689b3"
|
||||||
url: "https://github.com/tommyxchow/flutter-native-video-player.git"
|
url: "https://github.com/tommyxchow/flutter-native-video-player.git"
|
||||||
source: git
|
source: git
|
||||||
version: "0.4.11"
|
version: "0.4.11"
|
||||||
|
|
@ -118,34 +118,34 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: build
|
name: build
|
||||||
sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10
|
sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.6"
|
version: "4.0.7"
|
||||||
build_config:
|
build_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: build_config
|
name: build_config
|
||||||
sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71"
|
sha256: "94eaf6708fe64408c632ef2689ca3777b112f9421306ccf4f8c84d7c5c9f83f8"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.0"
|
version: "1.3.2"
|
||||||
build_daemon:
|
build_daemon:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: build_daemon
|
name: build_daemon
|
||||||
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
|
sha256: "79e05eaf15a48d7230b053a4363b8eaac0cc234bbd0134c3229455481f55cbc6"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.1"
|
version: "4.1.5"
|
||||||
build_runner:
|
build_runner:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: build_runner
|
name: build_runner
|
||||||
sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6"
|
sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.15.0"
|
version: "2.15.1"
|
||||||
built_collection:
|
built_collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -158,10 +158,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: built_value
|
name: built_value
|
||||||
sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56"
|
sha256: "31b24be6615ec7fcf70b3aa5a7469fe35826485e639a16dd7eb83ba30e4cc6a8"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.12.6"
|
version: "8.12.7"
|
||||||
cached_network_image:
|
cached_network_image:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -302,18 +302,18 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: dio
|
name: dio
|
||||||
sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
|
sha256: "0df44ebba85e503958eb75d07eedd3c86275a58c1d3eda2f2ce8f0a2c3abbb3c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.9.2"
|
version: "5.11.0"
|
||||||
dio_web_adapter:
|
dio_web_adapter:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: dio_web_adapter
|
name: dio_web_adapter
|
||||||
sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340"
|
sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.2.1"
|
||||||
dismissible_page:
|
dismissible_page:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -367,90 +367,90 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: firebase_analytics
|
name: firebase_analytics
|
||||||
sha256: "1f68d8a30265149035e9bb0b73962e43f3bf2debef4a7ae2d1d588361d5858a9"
|
sha256: a139dd0ada1c6e0ffd77bfdb877ac9061ffdec7c415e3e06113bdf8844db771f
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "12.4.2"
|
version: "12.4.6"
|
||||||
firebase_analytics_platform_interface:
|
firebase_analytics_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: firebase_analytics_platform_interface
|
name: firebase_analytics_platform_interface
|
||||||
sha256: "65f97fb923f036d487aa95be99eab90e0f8f636e4783c94deeb52c0e6f5dbae7"
|
sha256: "448c319ea895da002e43892c7d3f15dccbc3c4c3b81d3e307e37da885ea52bdf"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.2"
|
version: "6.0.6"
|
||||||
firebase_analytics_web:
|
firebase_analytics_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: firebase_analytics_web
|
name: firebase_analytics_web
|
||||||
sha256: ec49a95c6abaadbd6a4327cc2680d911f86e62fdf6f2c96a3a326603410e2abf
|
sha256: "67f287a0df75f27eafdd4a66a41e44f232c3aba713ea4794a1159893665a8bf5"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.1+8"
|
version: "0.6.1+12"
|
||||||
firebase_core:
|
firebase_core:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: firebase_core
|
name: firebase_core
|
||||||
sha256: ec46a100a560d3bd5f97f2d89ba7492cb09b6dd0a4a28753d1258f360d6bd9f9
|
sha256: "9478ca6700c02d315c6aba37e206e612317f98bb4f335bb1cbd6e0ce67dcf764"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.10.0"
|
version: "4.13.0"
|
||||||
firebase_core_platform_interface:
|
firebase_core_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: firebase_core_platform_interface
|
name: firebase_core_platform_interface
|
||||||
sha256: "4a120366dbf7d5a8ee9438978530b664b855728fb8dcc3a201017660817e555b"
|
sha256: e28f9afdcb5b0f0a8ea74ea3b322f5a7592c81cb45dd9d189913bdae08a2089a
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "8.1.0"
|
||||||
firebase_core_web:
|
firebase_core_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: firebase_core_web
|
name: firebase_core_web
|
||||||
sha256: "5ad1be848692ec148f2d6a8ad2a3838cb852ea5f3c9e6479a7afce479e1854f8"
|
sha256: f471a288b0101a45567548322ac4a5ad31e3ecbf87a576dcc0e424e6a11e04ea
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.8.0"
|
version: "3.10.0"
|
||||||
firebase_crashlytics:
|
firebase_crashlytics:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: firebase_crashlytics
|
name: firebase_crashlytics
|
||||||
sha256: "5d111db2b40426e76e2da95d133c19a9dbcf6500c4e9b3785c68da51fb3f6602"
|
sha256: "96c1d85de9eddc07061d3f7e2fb75596e75a45c9cec9c2e3a7cc9f97e6f3a748"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.2.3"
|
version: "5.2.7"
|
||||||
firebase_crashlytics_platform_interface:
|
firebase_crashlytics_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: firebase_crashlytics_platform_interface
|
name: firebase_crashlytics_platform_interface
|
||||||
sha256: "5bf4a83a1b9e0a5218f6d6491227440dd659242a55b16c279de0b8839791539a"
|
sha256: "47d83b71fd39c580297c696a507a7c2652680ea2045e40c6c4e3c371b0ee7bc6"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.8.23"
|
version: "3.8.27"
|
||||||
firebase_performance:
|
firebase_performance:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: firebase_performance
|
name: firebase_performance
|
||||||
sha256: a6b88d6933e02e4ba71f0641121dd7024f036b32a9929a01b638b3696a98e9ce
|
sha256: "570895b372791fca22080b369125fe13f3b6e1682ab0cbc204e2b46acd19b314"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.11.4+2"
|
version: "0.11.4+6"
|
||||||
firebase_performance_platform_interface:
|
firebase_performance_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: firebase_performance_platform_interface
|
name: firebase_performance_platform_interface
|
||||||
sha256: "6b9912709d315f4f2e0c87ddada8fe4f4d1b06aa74df52a8ced19fed087f0591"
|
sha256: "2979eac652173512ac07c45b8ce2ea2e37f16a7f8e4a003a3331405917886d72"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.0+2"
|
version: "0.2.0+6"
|
||||||
firebase_performance_web:
|
firebase_performance_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: firebase_performance_web
|
name: firebase_performance_web
|
||||||
sha256: "5ccf0a4511cd477406fe4c02ee2cabee5bd7d3c9e8cc5effdba5dcbd46b90064"
|
sha256: afb3b87ab2428fa86e4fb092c8069f1d860eb5be12ca5a57795648986093f23d
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.8+8"
|
version: "0.1.8+12"
|
||||||
fixnum:
|
fixnum:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -476,10 +476,10 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_cache_manager
|
name: flutter_cache_manager
|
||||||
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
|
sha256: "1de7849213b4c73c85aca7e0ac687a9a5d82ccdb594366b9dcc26cb6a2189cd2"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.4.1"
|
version: "3.4.2"
|
||||||
flutter_colorpicker:
|
flutter_colorpicker:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -508,10 +508,10 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_markdown_plus
|
name: flutter_markdown_plus
|
||||||
sha256: "039177906850278e8fb1cd364115ee0a46281135932fa8ecea8455522166d2de"
|
sha256: fce641d6c2106cc495de1cd603f97a4f9d615a97a64011928ded380dbdade935
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.7"
|
version: "1.0.12"
|
||||||
flutter_mobx:
|
flutter_mobx:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -622,10 +622,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: hooks
|
name: hooks
|
||||||
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
sha256: "03a564c3704524ee0f7fc56fc621e8796cc2eb8c24d1f1a33b34979815b285c4"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
version: "2.1.0"
|
||||||
html:
|
html:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -694,10 +694,10 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: intl
|
name: intl
|
||||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.20.2"
|
version: "0.20.3"
|
||||||
io:
|
io:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -710,18 +710,26 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: jni
|
name: jni
|
||||||
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
|
sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.3"
|
||||||
jni_flutter:
|
jni_flutter:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: jni_flutter
|
name: jni_flutter
|
||||||
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
|
sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.1"
|
version: "1.0.2"
|
||||||
|
jni_util:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni_util
|
||||||
|
sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
js:
|
js:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -742,10 +750,10 @@ packages:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: json_serializable
|
name: json_serializable
|
||||||
sha256: ffcd10cde35a93b2abbbcc26bd9971f4ca93763e8abe78d855e3c4177797e501
|
sha256: e45aefa0324f08c683caafbb94b72837aa6193c61822799c916e45f4a263113d
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.14.0"
|
version: "6.14.1"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -806,10 +814,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.19"
|
version: "0.12.20"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -822,10 +830,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.19.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -878,10 +886,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: objective_c
|
name: objective_c
|
||||||
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.4.1"
|
version: "9.5.0"
|
||||||
octo_image:
|
octo_image:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -934,10 +942,10 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path_provider
|
name: path_provider
|
||||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.5"
|
version: "2.1.6"
|
||||||
path_provider_android:
|
path_provider_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -958,18 +966,18 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_linux
|
name: path_provider_linux
|
||||||
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.1"
|
version: "2.2.2"
|
||||||
path_provider_platform_interface:
|
path_provider_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_platform_interface
|
name: path_provider_platform_interface
|
||||||
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.3"
|
||||||
path_provider_windows:
|
path_provider_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1022,10 +1030,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: posix
|
name: posix
|
||||||
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
|
sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.5.0"
|
version: "6.5.2"
|
||||||
provider:
|
provider:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -1054,10 +1062,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: record_use
|
name: record_use
|
||||||
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
sha256: "3b4ab682aff40175afca6ff090cc5aa7ce9dafe8dfbae1537a6c678e6678baee"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.0"
|
version: "1.1.0"
|
||||||
rxdart:
|
rxdart:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1078,10 +1086,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_android
|
name: shared_preferences_android
|
||||||
sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5"
|
sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.26"
|
version: "2.4.27"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1164,18 +1172,18 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: source_gen
|
name: source_gen
|
||||||
sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02
|
sha256: a603f1fb984a7391ae5978d1b92bfaaa08b350dca5c825256f925818f7943bf5
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.2.3"
|
version: "4.2.4"
|
||||||
source_helper:
|
source_helper:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: source_helper
|
name: source_helper
|
||||||
sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae"
|
sha256: "5e6f216fdf6376c9f3852381ae037499797a3385377d388b011dac98d303c67c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.12"
|
version: "1.3.13"
|
||||||
source_span:
|
source_span:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1212,10 +1220,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: sqflite_darwin
|
name: sqflite_darwin
|
||||||
sha256: "164a5d73ab87a134566057219988bafde837029a64264e61f1f04376ef3cfcd2"
|
sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.3"
|
version: "2.4.3+1"
|
||||||
sqflite_platform_interface:
|
sqflite_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1260,10 +1268,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: synchronized
|
name: synchronized
|
||||||
sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153"
|
sha256: "3a7b5d17422dd0f8d5c6c14feaa5a1c65638b9455f871a96f08437562c046931"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.4.1"
|
version: "3.4.1+2"
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1276,10 +1284,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.12"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1364,18 +1372,18 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: uuid
|
name: uuid
|
||||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.3"
|
version: "4.6.0"
|
||||||
vector_graphics:
|
vector_graphics:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_graphics
|
name: vector_graphics
|
||||||
sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d"
|
sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.2"
|
version: "1.2.3"
|
||||||
vector_graphics_codec:
|
vector_graphics_codec:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1388,18 +1396,18 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_graphics_compiler
|
name: vector_graphics_compiler
|
||||||
sha256: "7ee12e6dffe0fc8e755179d6d91b3b34f5924223fc104d85572ef9180d73d172"
|
sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.5"
|
version: "1.3.0"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_math
|
name: vector_math
|
||||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.4.2"
|
||||||
vm_service:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1420,10 +1428,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: wakelock_plus_platform_interface
|
name: wakelock_plus_platform_interface
|
||||||
sha256: b13f99e992e7ae6a152e16c5559d3c07ff445b13330192662494e614ca3e7d7b
|
sha256: "0618d1799f0b28bcf98255b4ee8313e6fc4d38589dc4ee5fe5840d57d1aff6da"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.1"
|
version: "1.6.0"
|
||||||
watcher:
|
watcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1460,18 +1468,18 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: webview_flutter
|
name: webview_flutter
|
||||||
sha256: e4d3474277c5043f5f3100ed27d94ad693abfd5c602b0221c719a4497e4ba1e4
|
sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.14.0"
|
version: "4.14.1"
|
||||||
webview_flutter_android:
|
webview_flutter_android:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: webview_flutter_android
|
name: webview_flutter_android
|
||||||
sha256: a97db7a44f8e71af2f3971c45550a08cce1fb60059c1b8e534251e6cfb753490
|
sha256: b98656fa4461f8cc05c48a778b4d4883e60ec63e1778348f363f9bb9a477745d
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.13.0"
|
version: "4.14.0"
|
||||||
webview_flutter_platform_interface:
|
webview_flutter_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1530,4 +1538,4 @@ packages:
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.12.0 <4.0.0"
|
dart: ">=3.12.0 <4.0.0"
|
||||||
flutter: "3.44.2"
|
flutter: "3.47.0"
|
||||||
|
|
|
||||||
15
pubspec.yaml
15
pubspec.yaml
|
|
@ -17,13 +17,13 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 5.1.0+69
|
version: 5.3.1+81
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.0
|
sdk: ^3.9.0
|
||||||
# Exact Flutter pin, enforced by `flutter pub get` locally and read by CI via
|
# Exact Flutter pin, enforced by `flutter pub get` locally and read by CI via
|
||||||
# `flutter-version-file: pubspec.yaml`. Single source of truth: bump here.
|
# `flutter-version-file: pubspec.yaml`. Single source of truth: bump here.
|
||||||
flutter: 3.44.2
|
flutter: 3.47.0
|
||||||
|
|
||||||
# Dependencies specify other packages that your package needs in order to work.
|
# Dependencies specify other packages that your package needs in order to work.
|
||||||
# To automatically upgrade your package dependencies to the latest versions
|
# To automatically upgrade your package dependencies to the latest versions
|
||||||
|
|
@ -34,12 +34,18 @@ environment:
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
json_annotation: ^4.6.0
|
json_annotation: ^4.12.0
|
||||||
|
|
||||||
# The following adds the Cupertino Icons font to your application.
|
# The following adds the Cupertino Icons font to your application.
|
||||||
# Use with the CupertinoIcons class for iOS style icons.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
cupertino_icons: ^1.0.2
|
cupertino_icons: ^1.0.2
|
||||||
web_socket_channel: ^3.0.0
|
web_socket_channel: ^3.0.0
|
||||||
|
# Pinned to v9 deliberately. v10.0.0 was shipped in 5.0.0 and reverted in
|
||||||
|
# 5.0.1 (ba28e09) while chasing the #484 startup freeze on low-end Android
|
||||||
|
# 14; that commit also made deleteAll() non-blocking, so which change fixed
|
||||||
|
# it was never established. v10 rewrote the Android ciphers, and v11 drops
|
||||||
|
# the legacy ones entirely, so a 9 -> 11 jump would also strand existing
|
||||||
|
# tokens. Move to v10 on its own, with low-end Android testing.
|
||||||
flutter_secure_storage: ^9.2.2
|
flutter_secure_storage: ^9.2.2
|
||||||
intl: ^0.20.1
|
intl: ^0.20.1
|
||||||
cached_network_image: ^3.1.0
|
cached_network_image: ^3.1.0
|
||||||
|
|
@ -50,6 +56,8 @@ dependencies:
|
||||||
shared_preferences: ^2.0.8
|
shared_preferences: ^2.0.8
|
||||||
flutter_svg: ^2.0.5
|
flutter_svg: ^2.0.5
|
||||||
url_launcher: ^6.0.17
|
url_launcher: ^6.0.17
|
||||||
|
# Held at v9/v12: the v10/v13 majors are just a win32 6.0.0 bump (Windows
|
||||||
|
# only), which conflicts with flutter_secure_storage v9's win32 ^5.
|
||||||
package_info_plus: ^9.0.0
|
package_info_plus: ^9.0.0
|
||||||
collection: ^1.16.0
|
collection: ^1.16.0
|
||||||
flutter_cache_manager: ^3.3.0
|
flutter_cache_manager: ^3.3.0
|
||||||
|
|
@ -143,6 +151,7 @@ flutter:
|
||||||
- assets/icons/logo.svg
|
- assets/icons/logo.svg
|
||||||
- assets/fonts/
|
- assets/fonts/
|
||||||
- assets/release-notes.md
|
- assets/release-notes.md
|
||||||
|
- assets/js/vaft.user.js
|
||||||
|
|
||||||
# An image asset can refer to one or more resolution-specific "variants", see
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
# https://flutter.dev/to/resolution-aware-images
|
# https://flutter.dev/to/resolution-aware-images
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,8 @@
|
||||||
// Then long-press the Anonymous account tile in the app to log in.
|
// Then long-press the Anonymous account tile in the app to log in.
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
const scopes =
|
import 'package:frosty/utils/twitch_oauth_scopes.dart';
|
||||||
'chat:read+chat:edit+user:read:follows+user:read:blocked_users+'
|
|
||||||
'user:manage:blocked_users+user:manage:chat_color';
|
|
||||||
const redirectUri = 'https://twitch.tv/login';
|
const redirectUri = 'https://twitch.tv/login';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
|
|
@ -24,11 +23,19 @@ void main() async {
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
final authorizeUrl = 'https://id.twitch.tv/oauth2/authorize'
|
final authorizeUrl = Uri(
|
||||||
'?client_id=$clientId'
|
scheme: 'https',
|
||||||
'&redirect_uri=$redirectUri'
|
host: 'id.twitch.tv',
|
||||||
'&response_type=token'
|
path: '/oauth2/authorize',
|
||||||
'&scope=$scopes';
|
queryParameters: {
|
||||||
|
'client_id': clientId,
|
||||||
|
'redirect_uri': redirectUri,
|
||||||
|
'response_type': 'token',
|
||||||
|
// Core scopes only — matches normal app login. Use the in-app
|
||||||
|
// "Enable moderator tools" flow when moderator scopes are needed.
|
||||||
|
'scope': twitchUserScopeQuery,
|
||||||
|
},
|
||||||
|
).toString();
|
||||||
|
|
||||||
stdout.writeln('Opening Twitch authorization in your browser...');
|
stdout.writeln('Opening Twitch authorization in your browser...');
|
||||||
await _openUrl(authorizeUrl);
|
await _openUrl(authorizeUrl);
|
||||||
|
|
|
||||||
235
scripts/setup-forgejo-release-secrets.sh
Executable file
235
scripts/setup-forgejo-release-secrets.sh
Executable file
|
|
@ -0,0 +1,235 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Generate Android release signing material + upload Forgejo Actions secrets for
|
||||||
|
# Obtainium APK builds — without printing secret values.
|
||||||
|
#
|
||||||
|
# Usage (from anywhere):
|
||||||
|
# bash /home/yuna/git/frosty/scripts/setup-forgejo-release-secrets.sh
|
||||||
|
#
|
||||||
|
# What it does:
|
||||||
|
# 1. Writes secrets to ~/.config/frosty/forgejo-release-secrets.env (mode 600)
|
||||||
|
# — generates a new upload keystore + passwords if the file is missing
|
||||||
|
# — merges CLIENT_ID / SECRET from frosty/.env when present (never echoes them)
|
||||||
|
# 2. PUTs each secret to Forgejo Actions on copilot/frosty
|
||||||
|
#
|
||||||
|
# Re-run safely: existing secrets file is reused (keystore not regenerated) unless
|
||||||
|
# FORCE_REGENERATE=1 bash …/setup-forgejo-release-secrets.sh
|
||||||
|
#
|
||||||
|
# Twitch Helix: if CLIENT_ID/SECRET are still empty after merge, the script stops
|
||||||
|
# before upload and tells you to edit the secrets file (add the two lines) then re-run.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO="${REPO:-copilot/frosty}"
|
||||||
|
FORGEJO_URL="${FORGEJO_URL:-https://forgejo.fsociety00.cc}"
|
||||||
|
TOKEN_FILE="${TOKEN_FILE:-${HOME}/.config/forgejo/token}"
|
||||||
|
SECRETS_DIR="${SECRETS_DIR:-${HOME}/.config/frosty}"
|
||||||
|
SECRETS_FILE="${SECRETS_FILE:-${SECRETS_DIR}/forgejo-release-secrets.env}"
|
||||||
|
KEYSTORE_FILE="${KEYSTORE_FILE:-${SECRETS_DIR}/upload-keystore.jks}"
|
||||||
|
FROSTY_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
ENV_FILE="${ENV_FILE:-${FROSTY_ROOT}/.env}"
|
||||||
|
KEY_ALIAS_DEFAULT="${KEY_ALIAS_DEFAULT:-upload}"
|
||||||
|
|
||||||
|
need_cmd() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || {
|
||||||
|
echo "ERROR: missing command: $1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
need_cmd curl
|
||||||
|
need_cmd python3
|
||||||
|
need_cmd openssl
|
||||||
|
need_cmd keytool
|
||||||
|
need_cmd base64
|
||||||
|
|
||||||
|
if [[ ! -f "$TOKEN_FILE" ]]; then
|
||||||
|
echo "ERROR: Forgejo token not found: $TOKEN_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
TOKEN="$(tr -d '[:space:]' <"$TOKEN_FILE")"
|
||||||
|
[[ -n "$TOKEN" ]] || {
|
||||||
|
echo "ERROR: empty token in $TOKEN_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$SECRETS_DIR"
|
||||||
|
chmod 700 "$SECRETS_DIR"
|
||||||
|
|
||||||
|
rand_pw() {
|
||||||
|
# URL/shell-safe password, no quotes
|
||||||
|
openssl rand -base64 24 | tr -d '\n/+=\r' | head -c 32
|
||||||
|
}
|
||||||
|
|
||||||
|
write_secrets_file() {
|
||||||
|
local store_pw key_pw alias b64
|
||||||
|
store_pw="$1"
|
||||||
|
key_pw="$2"
|
||||||
|
alias="$3"
|
||||||
|
b64="$4"
|
||||||
|
# Atomic write; never echo values
|
||||||
|
local tmp
|
||||||
|
tmp="$(mktemp "${SECRETS_DIR}/.secrets.XXXXXX")"
|
||||||
|
umask 077
|
||||||
|
cat >"$tmp" <<EOF
|
||||||
|
# Frosty Forgejo Actions release secrets — DO NOT COMMIT
|
||||||
|
# Generated: $(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
# Upload target: ${FORGEJO_URL}/${REPO}
|
||||||
|
|
||||||
|
RELEASE_KEYSTORE_BASE64=${b64}
|
||||||
|
STORE_PASSWORD=${store_pw}
|
||||||
|
KEY_ALIAS=${alias}
|
||||||
|
KEY_PASSWORD=${key_pw}
|
||||||
|
CLIENT_ID=
|
||||||
|
SECRET=
|
||||||
|
EOF
|
||||||
|
mv -f "$tmp" "$SECRETS_FILE"
|
||||||
|
chmod 600 "$SECRETS_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
merge_twitch_from_dotenv() {
|
||||||
|
# Pull CLIENT_ID / SECRET from frosty/.env into SECRETS_FILE if empty there.
|
||||||
|
# Uses python so values never hit the shell prompt or this script's stdout.
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
python3 - "$ENV_FILE" "$SECRETS_FILE" <<'PY'
|
||||||
|
import pathlib, re, sys
|
||||||
|
|
||||||
|
env_path, secrets_path = map(pathlib.Path, sys.argv[1:3])
|
||||||
|
env = {}
|
||||||
|
for line in env_path.read_text().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
k, _, v = line.partition("=")
|
||||||
|
env[k.strip()] = v.strip().strip('"').strip("'")
|
||||||
|
|
||||||
|
text = secrets_path.read_text()
|
||||||
|
changed = False
|
||||||
|
for key in ("CLIENT_ID", "SECRET"):
|
||||||
|
m = re.search(rf"^{key}=(.*)$", text, re.M)
|
||||||
|
current = m.group(1) if m else ""
|
||||||
|
incoming = env.get(key, "")
|
||||||
|
if not current and incoming:
|
||||||
|
if m:
|
||||||
|
text = re.sub(rf"^{key}=.*$", f"{key}={incoming}", text, count=1, flags=re.M)
|
||||||
|
else:
|
||||||
|
text += f"\n{key}={incoming}\n"
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
secrets_path.write_text(text)
|
||||||
|
print(f"merged Twitch keys from {env_path.name} into secrets file (values not shown)", file=sys.stderr)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
secrets_complete() {
|
||||||
|
python3 - "$SECRETS_FILE" <<'PY'
|
||||||
|
import pathlib, re, sys
|
||||||
|
text = pathlib.Path(sys.argv[1]).read_text()
|
||||||
|
needed = [
|
||||||
|
"RELEASE_KEYSTORE_BASE64",
|
||||||
|
"STORE_PASSWORD",
|
||||||
|
"KEY_ALIAS",
|
||||||
|
"KEY_PASSWORD",
|
||||||
|
"CLIENT_ID",
|
||||||
|
"SECRET",
|
||||||
|
]
|
||||||
|
missing = []
|
||||||
|
for key in needed:
|
||||||
|
m = re.search(rf"^{key}=(.*)$", text, re.M)
|
||||||
|
if not m or not m.group(1).strip():
|
||||||
|
missing.append(key)
|
||||||
|
if missing:
|
||||||
|
print("MISSING:" + ",".join(missing))
|
||||||
|
sys.exit(2)
|
||||||
|
print("OK")
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
put_secret() {
|
||||||
|
local name="$1"
|
||||||
|
# Read value from secrets file without printing it
|
||||||
|
local payload code
|
||||||
|
payload="$(
|
||||||
|
python3 - "$SECRETS_FILE" "$name" <<'PY'
|
||||||
|
import json, pathlib, re, sys
|
||||||
|
text = pathlib.Path(sys.argv[1]).read_text()
|
||||||
|
name = sys.argv[2]
|
||||||
|
m = re.search(rf"^{re.escape(name)}=(.*)$", text, re.M)
|
||||||
|
if not m:
|
||||||
|
raise SystemExit(f"missing {name}")
|
||||||
|
print(json.dumps({"data": m.group(1)}))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
code="$(
|
||||||
|
curl -sS -o /tmp/frosty-forgejo-secret-resp.json -w '%{http_code}' \
|
||||||
|
-X PUT "${FORGEJO_URL}/api/v1/repos/${REPO}/actions/secrets/${name}" \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$payload"
|
||||||
|
)"
|
||||||
|
if [[ "$code" != "201" && "$code" != "204" && "$code" != "200" ]]; then
|
||||||
|
echo "ERROR: PUT secret ${name} → HTTP ${code}" >&2
|
||||||
|
# Response may contain error text only — still avoid dumping request body
|
||||||
|
python3 -c 'import pathlib; p=pathlib.Path("/tmp/frosty-forgejo-secret-resp.json"); print(p.read_text()[:500] if p.exists() else "")' >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " set ${name} (HTTP ${code})"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- generate if needed ---
|
||||||
|
if [[ ! -f "$SECRETS_FILE" || "${FORCE_REGENERATE:-0}" == "1" ]]; then
|
||||||
|
echo "→ Generating new upload keystore + passwords → ${SECRETS_FILE}"
|
||||||
|
STORE_PW="$(rand_pw)"
|
||||||
|
KEY_PW="$(rand_pw)"
|
||||||
|
ALIAS="$KEY_ALIAS_DEFAULT"
|
||||||
|
rm -f "$KEYSTORE_FILE"
|
||||||
|
keytool -genkeypair \
|
||||||
|
-keystore "$KEYSTORE_FILE" \
|
||||||
|
-storetype JKS \
|
||||||
|
-storepass "$STORE_PW" \
|
||||||
|
-keypass "$KEY_PW" \
|
||||||
|
-alias "$ALIAS" \
|
||||||
|
-keyalg RSA \
|
||||||
|
-keysize 2048 \
|
||||||
|
-validity 10000 \
|
||||||
|
-dname "CN=Frosty Forgejo Release, OU=Newas, O=fsociety00, L=Home, ST=NA, C=DE" \
|
||||||
|
>/dev/null 2>&1
|
||||||
|
chmod 600 "$KEYSTORE_FILE"
|
||||||
|
B64="$(base64 -w0 "$KEYSTORE_FILE" 2>/dev/null || base64 "$KEYSTORE_FILE" | tr -d '\n')"
|
||||||
|
write_secrets_file "$STORE_PW" "$KEY_PW" "$ALIAS" "$B64"
|
||||||
|
# Drop locals
|
||||||
|
unset STORE_PW KEY_PW B64
|
||||||
|
echo " wrote keystore ${KEYSTORE_FILE}"
|
||||||
|
echo " wrote secrets file (mode 600)"
|
||||||
|
else
|
||||||
|
echo "→ Reusing existing secrets file: ${SECRETS_FILE}"
|
||||||
|
echo " (set FORCE_REGENERATE=1 to mint a new keystore)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "→ Merging Twitch CLIENT_ID/SECRET from ${ENV_FILE} if present…"
|
||||||
|
merge_twitch_from_dotenv || true
|
||||||
|
|
||||||
|
if ! status="$(secrets_complete)"; then
|
||||||
|
echo >&2
|
||||||
|
echo "Twitch Helix secrets still missing in ${SECRETS_FILE}." >&2
|
||||||
|
echo "Edit that file and set CLIENT_ID=… and SECRET=… (from https://dev.twitch.tv/console)," >&2
|
||||||
|
echo "or put them in ${ENV_FILE}, then re-run this script." >&2
|
||||||
|
echo "Signing secrets are already generated; they will be uploaded together." >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
echo "→ Secrets file complete (${status})"
|
||||||
|
|
||||||
|
echo "→ Uploading Actions secrets to ${FORGEJO_URL}/${REPO} (values not printed)"
|
||||||
|
for name in RELEASE_KEYSTORE_BASE64 STORE_PASSWORD KEY_ALIAS KEY_PASSWORD CLIENT_ID SECRET; do
|
||||||
|
put_secret "$name"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Done. Secrets live only in:"
|
||||||
|
echo " ${SECRETS_FILE}"
|
||||||
|
echo " ${KEYSTORE_FILE}"
|
||||||
|
echo " Forgejo Actions → ${REPO}"
|
||||||
|
echo
|
||||||
|
echo "Next: Forgejo → Actions → Release APK → Run workflow"
|
||||||
|
echo "Obtainium: https://forgejo.fsociety00.cc/api/v1/repos/copilot/frosty/releases"
|
||||||
|
echo "APK filter: frosty-arm64-v8a-v.*\\.apk"
|
||||||
|
|
@ -331,6 +331,41 @@ void main() {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const timeoutCases = {
|
||||||
|
DioExceptionType.connectionTimeout: 'Connection timeout',
|
||||||
|
DioExceptionType.sendTimeout: 'Upload timeout',
|
||||||
|
DioExceptionType.receiveTimeout: 'Server response timeout',
|
||||||
|
DioExceptionType.transformTimeout: 'Response processing timeout',
|
||||||
|
};
|
||||||
|
|
||||||
|
timeoutCases.forEach((type, expectedMessage) {
|
||||||
|
test('${type.name} throws TimeoutException', () async {
|
||||||
|
final path = '/${type.name}';
|
||||||
|
|
||||||
|
dioAdapter.onGet(
|
||||||
|
'https://api.test.com$path',
|
||||||
|
(server) => server.throws(
|
||||||
|
0,
|
||||||
|
DioException(
|
||||||
|
requestOptions: RequestOptions(path: path),
|
||||||
|
type: type,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => client.testGet(path),
|
||||||
|
throwsA(
|
||||||
|
isA<TimeoutException>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains(expectedMessage),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('successful get()', () {
|
group('successful get()', () {
|
||||||
|
|
|
||||||
84
test/apis/twitch_ad_solutions_test.dart
Normal file
84
test/apis/twitch_ad_solutions_test.dart
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:frosty/apis/twitch_ad_solutions.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('TwitchAdSolutions', () {
|
||||||
|
test('playlistContainsAds detects stitched signifier', () {
|
||||||
|
expect(
|
||||||
|
TwitchAdSolutions.playlistContainsAds(
|
||||||
|
'#EXTM3U\n#EXT-X-DATERANGE:ID="stitched-ad-1"\n',
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
TwitchAdSolutions.playlistContainsAds('#EXTM3U\n#EXTINF:2.0,\nlive.ts\n'),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('playlistIsMidroll detects midroll markers', () {
|
||||||
|
expect(
|
||||||
|
TwitchAdSolutions.playlistIsMidroll('X-TV-TWITCH-AD-POD-TYPE="MIDROLL"'),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(TwitchAdSolutions.playlistIsMidroll('live only'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('platformForPlayerType matches vaft', () {
|
||||||
|
expect(TwitchAdSolutions.platformForPlayerType('autoplay'), 'android');
|
||||||
|
expect(TwitchAdSolutions.platformForPlayerType('popout'), 'web');
|
||||||
|
expect(TwitchAdSolutions.platformForPlayerType('embed'), 'web');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('force / backup player types match vaft defaults', () {
|
||||||
|
expect(TwitchAdSolutions.forceAccessTokenPlayerType, 'popout');
|
||||||
|
expect(TwitchAdSolutions.backupPlayerTypes, [
|
||||||
|
'embed',
|
||||||
|
'popout',
|
||||||
|
'autoplay',
|
||||||
|
]);
|
||||||
|
expect(TwitchAdSolutions.fallbackPlayerType, 'embed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('TwitchAdFreePlayback.firstMediaPlaylistUrl', () {
|
||||||
|
test('resolves relative media playlist against base', () {
|
||||||
|
const master = '''
|
||||||
|
#EXTM3U
|
||||||
|
#EXT-X-STREAM-INF:BANDWIDTH=1000,RESOLUTION=640x360
|
||||||
|
360p.m3u8
|
||||||
|
''';
|
||||||
|
final url = TwitchAdFreePlayback.firstMediaPlaylistUrl(
|
||||||
|
master,
|
||||||
|
baseUrl: 'https://usher.ttvnw.net/api/v2/channel/hls/foo.m3u8?x=1',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
url,
|
||||||
|
'https://usher.ttvnw.net/api/v2/channel/hls/360p.m3u8',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps absolute media playlist URLs', () {
|
||||||
|
const master = '''
|
||||||
|
#EXTM3U
|
||||||
|
#EXT-X-STREAM-INF:BANDWIDTH=1000
|
||||||
|
https://video-weaver.example/v1/playlist/abc.m3u8
|
||||||
|
''';
|
||||||
|
final url = TwitchAdFreePlayback.firstMediaPlaylistUrl(
|
||||||
|
master,
|
||||||
|
baseUrl: 'https://usher.ttvnw.net/foo.m3u8',
|
||||||
|
);
|
||||||
|
expect(url, 'https://video-weaver.example/v1/playlist/abc.m3u8');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when no media URI present', () {
|
||||||
|
expect(
|
||||||
|
TwitchAdFreePlayback.firstMediaPlaylistUrl(
|
||||||
|
'#EXTM3U\n',
|
||||||
|
baseUrl: 'https://example.com/x.m3u8',
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -215,26 +215,27 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
group('validateToken', () {
|
group('validateToken', () {
|
||||||
test('returns true for valid token', () async {
|
test('returns scopes and user id for valid token', () async {
|
||||||
dioAdapter.onGet(
|
dioAdapter.onGet(
|
||||||
'https://id.twitch.tv/oauth2/validate',
|
'https://id.twitch.tv/oauth2/validate',
|
||||||
(server) => server.reply(200, twitchValidateTokenResponse),
|
(server) => server.reply(200, twitchValidateTokenResponse),
|
||||||
);
|
);
|
||||||
|
|
||||||
final isValid = await api.validateToken(token: 'valid_token');
|
final info = await api.validateToken(token: 'valid_token');
|
||||||
|
|
||||||
expect(isValid, isTrue);
|
expect(info?.scopes, ['chat:read', 'chat:edit']);
|
||||||
|
expect(info?.userId, '12345');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns false for expired token (401)', () async {
|
test('returns null for expired token (401)', () async {
|
||||||
dioAdapter.onGet(
|
dioAdapter.onGet(
|
||||||
'https://id.twitch.tv/oauth2/validate',
|
'https://id.twitch.tv/oauth2/validate',
|
||||||
(server) => server.reply(401, {'message': 'invalid access token'}),
|
(server) => server.reply(401, {'message': 'invalid access token'}),
|
||||||
);
|
);
|
||||||
|
|
||||||
final isValid = await api.validateToken(token: 'expired_token');
|
final info = await api.validateToken(token: 'expired_token');
|
||||||
|
|
||||||
expect(isValid, isFalse);
|
expect(info, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rethrows on server error', () {
|
test('rethrows on server error', () {
|
||||||
|
|
@ -586,4 +587,55 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('isFollowingChannel', () {
|
||||||
|
test('returns true when Helix lists the broadcaster', () async {
|
||||||
|
dioAdapter.onGet(
|
||||||
|
'https://api.twitch.tv/helix/channels/followed',
|
||||||
|
(server) => server.reply(200, {
|
||||||
|
'total': 1,
|
||||||
|
'data': [
|
||||||
|
{
|
||||||
|
'broadcaster_id': '999',
|
||||||
|
'broadcaster_login': 'streamer',
|
||||||
|
'broadcaster_name': 'Streamer',
|
||||||
|
'followed_at': '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'pagination': <String, dynamic>{},
|
||||||
|
}),
|
||||||
|
queryParameters: {
|
||||||
|
'user_id': '12345',
|
||||||
|
'broadcaster_id': '999',
|
||||||
|
'first': '1',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await api.isFollowingChannel(userId: '12345', broadcasterId: '999'),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when not following', () async {
|
||||||
|
dioAdapter.onGet(
|
||||||
|
'https://api.twitch.tv/helix/channels/followed',
|
||||||
|
(server) => server.reply(200, {
|
||||||
|
'total': 0,
|
||||||
|
'data': <dynamic>[],
|
||||||
|
'pagination': <String, dynamic>{},
|
||||||
|
}),
|
||||||
|
queryParameters: {
|
||||||
|
'user_id': '12345',
|
||||||
|
'broadcaster_id': '999',
|
||||||
|
'first': '1',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await api.isFollowingChannel(userId: '12345', broadcasterId: '999'),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
102
test/apis/twitch_gql_follow_test.dart
Normal file
102
test/apis/twitch_gql_follow_test.dart
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:frosty/apis/base_api_client.dart';
|
||||||
|
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||||
|
import 'package:http_mock_adapter/http_mock_adapter.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late Dio dio;
|
||||||
|
late DioAdapter dioAdapter;
|
||||||
|
late TwitchGqlApi api;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
dio = Dio(BaseOptions());
|
||||||
|
dioAdapter = DioAdapter(dio: dio);
|
||||||
|
api = TwitchGqlApi(dio);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('followUser', () {
|
||||||
|
test('posts FollowButton_FollowUser persisted mutation', () async {
|
||||||
|
dioAdapter.onPost(
|
||||||
|
'https://gql.twitch.tv/gql',
|
||||||
|
(server) => server.reply(200, {
|
||||||
|
'data': {
|
||||||
|
'followUser': {
|
||||||
|
'follow': {
|
||||||
|
'user': {'id': '999', 'displayName': 'Streamer'},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
data: Matchers.any,
|
||||||
|
);
|
||||||
|
|
||||||
|
await api.followUser(targetId: '999', authToken: 'web-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws IntegrityCheckException on integrity errors', () async {
|
||||||
|
dioAdapter.onPost(
|
||||||
|
'https://gql.twitch.tv/gql',
|
||||||
|
(server) => server.reply(200, {
|
||||||
|
'errors': [
|
||||||
|
{'message': 'failed integrity check'},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
data: Matchers.any,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
api.followUser(targetId: '999', authToken: 'web-token'),
|
||||||
|
throwsA(isA<IntegrityCheckException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('unfollowUser', () {
|
||||||
|
test('posts FollowButton_UnfollowUser persisted mutation', () async {
|
||||||
|
dioAdapter.onPost(
|
||||||
|
'https://gql.twitch.tv/gql',
|
||||||
|
(server) => server.reply(200, {
|
||||||
|
'data': {
|
||||||
|
'unfollowUser': {
|
||||||
|
'follow': null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
data: Matchers.any,
|
||||||
|
);
|
||||||
|
|
||||||
|
await api.unfollowUser(targetId: '999', authToken: 'web-token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('fetchIntegrityToken', () {
|
||||||
|
test('rejects bad_bot tokens', () async {
|
||||||
|
final payload = base64Url.encode(
|
||||||
|
utf8.encode(
|
||||||
|
jsonEncode({
|
||||||
|
'is_bad_bot': 'true',
|
||||||
|
'device_id': 'dev',
|
||||||
|
'client_id': 'kimne78kx3ncx6brgo4mv6wki5h1ko',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
dioAdapter.onPost(
|
||||||
|
'https://gql.twitch.tv/integrity',
|
||||||
|
(server) => server.reply(200, {
|
||||||
|
'token': 'v4.public.$payload.sig',
|
||||||
|
'expiration': DateTime.now().millisecondsSinceEpoch + 600000,
|
||||||
|
}),
|
||||||
|
data: Matchers.any,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
api.fetchIntegrityToken(authToken: 'web', deviceId: 'dev'),
|
||||||
|
throwsA(isA<IntegrityCheckException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
129
test/apis/twitch_gql_streams_test.dart
Normal file
129
test/apis/twitch_gql_streams_test.dart
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:frosty/apis/base_api_client.dart';
|
||||||
|
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||||
|
import 'package:http_mock_adapter/http_mock_adapter.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late Dio dio;
|
||||||
|
late DioAdapter dioAdapter;
|
||||||
|
late TwitchGqlApi api;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
dio = Dio(BaseOptions());
|
||||||
|
dioAdapter = DioAdapter(dio: dio);
|
||||||
|
api = TwitchGqlApi(dio);
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, Object?> streamEdge({
|
||||||
|
required String id,
|
||||||
|
required String login,
|
||||||
|
required int viewers,
|
||||||
|
String? cursor,
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
'cursor': cursor ?? 'cursor-$id',
|
||||||
|
'node': {
|
||||||
|
'id': id,
|
||||||
|
'title': '$login live',
|
||||||
|
'viewersCount': viewers,
|
||||||
|
'createdAt': '2026-01-01T00:00:00Z',
|
||||||
|
'language': 'en',
|
||||||
|
'game': {'id': '1', 'name': 'Game'},
|
||||||
|
'broadcaster': {
|
||||||
|
'id': id,
|
||||||
|
'login': login,
|
||||||
|
'displayName': login,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
group('getStreams', () {
|
||||||
|
test('parses freeform tag page and cursor', () async {
|
||||||
|
dioAdapter.onPost(
|
||||||
|
'https://gql.twitch.tv/gql',
|
||||||
|
(server) => server.reply(200, {
|
||||||
|
'data': {
|
||||||
|
'streams': {
|
||||||
|
'edges': [
|
||||||
|
streamEdge(id: '1', login: 'a', viewers: 10),
|
||||||
|
streamEdge(id: '2', login: 'b', viewers: 5),
|
||||||
|
],
|
||||||
|
'pageInfo': {'hasNextPage': true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
data: Matchers.any,
|
||||||
|
);
|
||||||
|
|
||||||
|
final page = await api.getStreams(
|
||||||
|
freeformTags: ['Deutsch'],
|
||||||
|
sort: StreamViewerSort.viewersAsc,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(page.data.map((s) => s.userLogin), ['a', 'b']);
|
||||||
|
expect(page.data.map((s) => s.viewerCount), [10, 5]);
|
||||||
|
expect(page.pagination['cursor'], 'cursor-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws IntegrityCheckException on integrity errors', () async {
|
||||||
|
dioAdapter.onPost(
|
||||||
|
'https://gql.twitch.tv/gql',
|
||||||
|
(server) => server.reply(200, {
|
||||||
|
'errors': [
|
||||||
|
{'message': 'failed integrity check'},
|
||||||
|
],
|
||||||
|
'data': {'streams': null},
|
||||||
|
}),
|
||||||
|
data: Matchers.any,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
api.getStreams(
|
||||||
|
freeformTags: ['Deutsch'],
|
||||||
|
cursor: 'page-2',
|
||||||
|
authToken: 'web',
|
||||||
|
integrityToken: 'stale',
|
||||||
|
deviceId: 'device',
|
||||||
|
),
|
||||||
|
throwsA(isA<IntegrityCheckException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sends Client-Integrity and X-Device-Id when provided', () async {
|
||||||
|
dioAdapter.onPost(
|
||||||
|
'https://gql.twitch.tv/gql',
|
||||||
|
(server) => server.reply(200, {
|
||||||
|
'data': {
|
||||||
|
'streams': {
|
||||||
|
'edges': [streamEdge(id: '1', login: 'a', viewers: 1)],
|
||||||
|
'pageInfo': {'hasNextPage': false},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
data: Matchers.any,
|
||||||
|
headers: {
|
||||||
|
'Client-ID': Matchers.any,
|
||||||
|
'Content-Type': Matchers.any,
|
||||||
|
'User-Agent': Matchers.any,
|
||||||
|
'Authorization': 'OAuth web-token',
|
||||||
|
'X-Device-Id': 'dev-123',
|
||||||
|
'Client-Integrity': 'integrity-token',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final page = await api.getStreams(
|
||||||
|
freeformTags: ['English'],
|
||||||
|
sort: StreamViewerSort.viewersDesc,
|
||||||
|
cursor: 'after-1',
|
||||||
|
authToken: 'web-token',
|
||||||
|
integrityToken: 'integrity-token',
|
||||||
|
deviceId: 'dev-123',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(page.data, hasLength(1));
|
||||||
|
expect(page.pagination, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
42
test/apis/twitch_integrity_test.dart
Normal file
42
test/apis/twitch_integrity_test.dart
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:frosty/apis/twitch_integrity.dart';
|
||||||
|
|
||||||
|
String _tokenFor(Map<String, Object?> payload) {
|
||||||
|
final encoded = base64Url.encode(utf8.encode(jsonEncode(payload)));
|
||||||
|
return 'v4.public.$encoded.fakesig';
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('TwitchIntegrity', () {
|
||||||
|
test('detects is_bad_bot string flag', () {
|
||||||
|
final token = _tokenFor({
|
||||||
|
'client_id': 'kimne78kx3ncx6brgo4mv6wki5h1ko',
|
||||||
|
'device_id': 'abc123',
|
||||||
|
'is_bad_bot': 'true',
|
||||||
|
'exp': '2099-01-01T00:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(TwitchIntegrity.isBadBot(token), isTrue);
|
||||||
|
expect(TwitchIntegrity.deviceId(token), 'abc123');
|
||||||
|
expect(TwitchIntegrity.expirationMs(token), isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects is_bad_bot bool flag', () {
|
||||||
|
final token = _tokenFor({
|
||||||
|
'is_bad_bot': true,
|
||||||
|
'device_id': 'dev',
|
||||||
|
});
|
||||||
|
expect(TwitchIntegrity.isBadBot(token), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when not flagged', () {
|
||||||
|
final token = _tokenFor({
|
||||||
|
'is_bad_bot': 'false',
|
||||||
|
'device_id': 'dev',
|
||||||
|
});
|
||||||
|
expect(TwitchIntegrity.isBadBot(token), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
20
test/apis/unauthorized_interceptor_paths_test.dart
Normal file
20
test/apis/unauthorized_interceptor_paths_test.dart
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:frosty/apis/unauthorized_interceptor.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('moderation path helpers', () {
|
||||||
|
test('matches helix moderation paths', () {
|
||||||
|
expect(
|
||||||
|
isTwitchModeratedChannelsPath('/helix/moderation/channels'),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(isTwitchModeratorActionPath('/helix/moderation/chat'), isTrue);
|
||||||
|
expect(isTwitchModeratorActionPath('/helix/moderation/bans'), isTrue);
|
||||||
|
expect(
|
||||||
|
isTwitchModeratorActionPath('/helix/moderation/channels'),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(isTwitchModeratedChannelsPath('/helix/users'), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
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