Compare commits

..

1 commit

Author SHA1 Message Date
dependabot[bot]
a9d95f7657
ci: bump actions/checkout from 4 to 6 in the actions group
Bumps the actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 4 to 6
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-15 23:32:54 +00:00
164 changed files with 7537 additions and 19071 deletions

31
.claude/settings.json Normal file
View file

@ -0,0 +1,31 @@
{
"enabledMcpjsonServers": ["next-devtools"],
"enabledPlugins": {
"frontend-design@claude-plugins-official": true,
"feature-dev@claude-plugins-official": true,
"commit-commands@claude-plugins-official": true,
"pr-review-toolkit@claude-plugins-official": true,
"typescript-lsp@claude-plugins-official": true,
"claude-md-management@claude-plugins-official": true,
"claude-code-setup@claude-plugins-official": true,
"skill-creator@claude-plugins-official": true
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "dart format \"$CLAUDE_FILE_PATH\" 2>/dev/null || true"
}
]
}
]
},
"sandbox": {
"enabled": true,
"autoAllowBashIfSandboxed": true,
"enableWeakerNetworkIsolation": true
}
}

View file

@ -1,12 +0,0 @@
{
"mcpServers": {
"dart": {
"command": "dart",
"args": ["mcp-server", "--force-roots-fallback"]
},
"next-devtools": {
"command": "node",
"args": ["web/node_modules/next-devtools-mcp/dist/index.js"]
}
}
}

View file

@ -1,14 +0,0 @@
# 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"

View file

@ -1,304 +0,0 @@
# 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"

View file

@ -3,57 +3,18 @@ on:
push: push:
branches: [main] branches: [main]
jobs: jobs:
# Linux runner: analyze + test once (both are OS-independent) and compile a test:
# release APK to catch release-only breakage. Debug-signed via the build.gradle name: Check and test on ${{ matrix.os }}
# fallback (ALLOW_DEBUG_SIGNED_RELEASE) — no signing secrets needed. runs-on: ${{ matrix.os }}
android: strategy:
name: Analyze, test, and build Android matrix:
runs-on: ubuntu-latest os: [ubuntu-latest, windows-latest, macos-latest]
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
# Match the JDK the release workflows build with, so this compile check
# faithfully catches what would break a real release.
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
- uses: subosito/flutter-action@v2 - uses: subosito/flutter-action@v2
with: with:
flutter-version-file: pubspec.yaml
cache: true cache: true
- run: flutter doctor -v - run: flutter doctor -v
- run: flutter pub get - run: flutter pub get
# Fail if committed generated files (*.g.dart) are stale — CI never runs
# build_runner otherwise, so forgetting to regenerate after editing a MobX
# store or @JsonSerializable model slips past analyze/test/build.
- run: dart run build_runner build --delete-conflicting-outputs
- name: Verify generated files are up to date
run: |
if [ -n "$(git status --porcelain)" ]; then
echo '::error::Generated files are out of date. Run `dart run build_runner build --delete-conflicting-outputs` and commit the result.'
git status --porcelain
git diff
exit 1
fi
- run: flutter analyze - run: flutter analyze
- run: flutter test - run: flutter test
- run: flutter build apk --release
env:
ALLOW_DEBUG_SIGNED_RELEASE: '1'
# macOS runner: release-compile iOS (no codesign, no secrets) — the only thing
# that must run on macOS. analyze/test already covered on the Linux job.
ios:
name: Build iOS
runs-on: macos-latest
steps:
- uses: actions/checkout@v6
# Match release workflows and avoid GitHub's lagging default Xcode.
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- uses: subosito/flutter-action@v2
with:
flutter-version-file: pubspec.yaml
cache: true
- run: flutter pub get
- run: flutter build ios --release --no-codesign

View file

@ -0,0 +1,44 @@
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

50
.github/workflows/claude.yml vendored Normal file
View file

@ -0,0 +1,50 @@
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:*)'

View file

@ -32,7 +32,6 @@ jobs:
- name: Set up Flutter - name: Set up Flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
with: with:
flutter-version-file: pubspec.yaml
cache: true cache: true
# Decode and create the upload-keystore.jks file, used for signing the app. # Decode and create the upload-keystore.jks file, used for signing the app.
@ -97,7 +96,7 @@ jobs:
# Create a draft release on GitHub. # Create a draft release on GitHub.
- name: Release - name: Release
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v2
with: with:
draft: true draft: true
generate_release_notes: ${{ steps.release_notes.outputs.found != 'true' }} generate_release_notes: ${{ steps.release_notes.outputs.found != 'true' }}

View file

@ -8,36 +8,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
# Match the JDK the release workflows build with, so the release-mode APK
# check below faithfully catches what would break a real release.
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
- uses: subosito/flutter-action@v2 - uses: subosito/flutter-action@v2
with: with:
flutter-version-file: pubspec.yaml
cache: true cache: true
- run: flutter doctor -v - run: flutter doctor -v
- run: flutter pub get - run: flutter pub get
# Fail if committed generated files (*.g.dart) are stale — CI never runs
# build_runner otherwise, so forgetting to regenerate after editing a MobX
# store or @JsonSerializable model slips past analyze/test/build.
- run: dart run build_runner build --delete-conflicting-outputs
- name: Verify generated files are up to date
run: |
if [ -n "$(git status --porcelain)" ]; then
echo '::error::Generated files are out of date. Run `dart run build_runner build --delete-conflicting-outputs` and commit the result.'
git status --porcelain
git diff
exit 1
fi
- run: flutter analyze - run: flutter analyze
- run: flutter test - run: flutter test
# Release-mode compile check (no signing secrets needed — Android falls back
# to debug signing via ALLOW_DEBUG_SIGNED_RELEASE). Catches release-only
# breakage that debug builds and tests miss (AOT compile, icon tree-shaking,
# plugin/Gradle release incompatibility).
- run: flutter build apk --release
env:
ALLOW_DEBUG_SIGNED_RELEASE: '1'

View file

@ -1,126 +0,0 @@
# Dry-runs the full store release pipeline without publishing, to catch
# credential, signing, dependency, and store-validation breakage before an
# actual release. Mirrors store-release.yml but stops short of publishing:
# Android validates the AAB against Google Play (validate_only); iOS builds a
# fully signed archive via match (readonly) and skips the App Store upload.
name: Release dry-run
on:
# Run on demand before cutting a release.
workflow_dispatch:
# Monthly heartbeat so cert expiry, secret rotation, and dependency drift
# surface on their own between releases.
schedule:
- cron: '0 9 1 * *' # 1st of the month, 09:00 UTC
# Setup environment variables for use in all jobs.
env:
# Gemfile path used for caching dependencies.
BUNDLE_GEMFILE: ${{ github.workspace }}/android/Gemfile
CLIENT_ID: ${{ secrets.CLIENT_ID }}
SECRET: ${{ secrets.SECRET }}
LC_ALL: en_US.UTF-8
LANG: en_US.UTF-8
jobs:
android:
name: Dry-run Android release
runs-on: ubuntu-latest
environment: release-android
env:
UPLOAD_KEYSTORE: ${{ secrets.UPLOAD_KEYSTORE }}
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
KEY_FILE_PATH: android/key.properties
PLAY_STORE_CREDENTIALS: ${{ secrets.PLAY_STORE_CREDENTIALS }}
steps:
- name: Checkout Frosty repo
uses: actions/checkout@v6
# Install Ruby and install/cache dependencies with bundler in order to use fastlane.
- name: Set up Ruby and Bundler
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.3
bundler-cache: true
# Setup Java so that we can build the appbundle.
- name: Set up Java
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
# Setup and cache Flutter to install packages and build.
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version-file: pubspec.yaml
cache: true
# Decode and create the upload-keystore.jks file, used for signing the app.
- name: Decode and create upload-keystore.jks
run: echo $UPLOAD_KEYSTORE | base64 --decode > $GITHUB_WORKSPACE/android/upload-keystore.jks
# Create the key.properties file, used for signing the app.
- name: Create key.properties
run: |
echo storePassword=$STORE_PASSWORD >> $KEY_FILE_PATH
echo keyPassword=$KEY_PASSWORD >> $KEY_FILE_PATH
echo keyAlias=upload >> $KEY_FILE_PATH
echo storeFile=$GITHUB_WORKSPACE/android/upload-keystore.jks >> $KEY_FILE_PATH
# Build the Android app bundle (AAB) and pass the environment variables.
#
# The version code is overridden because Play rejects one it has already
# seen, and pubspec's code is by definition already published between
# releases — so the dry-run would fail on every scheduled run and never
# reach the validation it exists to perform. Epoch seconds is always
# unused, always increasing, and well under Play's 2100000000 cap. The
# bundle is only ever validated, never published, so the value is thrown
# away. versionName still comes from pubspec.
- name: Build the Android AAB
run: flutter build appbundle --build-number=$(date +%s) --dart-define CLIENT_ID=$CLIENT_ID --dart-define SECRET=$SECRET
# Validate the AAB and credentials against Google Play without publishing.
- name: Validate release with fastlane (no publish)
run: cd android && bundle exec fastlane deploy dry_run:true
ios:
name: Dry-run iOS release
runs-on: macos-latest
environment: release-ios
env:
APP_STORE_KEY_ID: ${{ secrets.APP_STORE_KEY_ID }}
APP_STORE_ISSUER_ID: ${{ secrets.APP_STORE_ISSUER_ID }}
APP_STORE_CREDENTIALS: ${{ secrets.APP_STORE_CREDENTIALS }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
steps:
- name: Checkout Frosty repo
uses: actions/checkout@v6
# Always pick the newest production Xcode on the runner.
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
# Install Ruby and install/cache dependencies with bundler in order to use fastlane.
- name: Set up Ruby and Bundler
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.3
bundler-cache: true
# Setup and cache Flutter to install packages and build.
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version-file: pubspec.yaml
cache: true
# Setup the iOS project with environment variables without signing (building and signing will happen in fastlane).
- name: Config the iOS build
run: flutter build ios --no-codesign --config-only --dart-define CLIENT_ID=$CLIENT_ID --dart-define SECRET=$SECRET
# Authenticate ASC API key, resolve signing via match (readonly), and build a
# fully signed archive without uploading to the App Store.
- name: Sign and build with fastlane (no publish)
run: cd ios && bundle exec fastlane release dry_run:true

View file

@ -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.3 ruby-version: 3.2
bundler-cache: true bundler-cache: true
# Setup Java so that we can build the appbundle. # Setup Java so that we can build the appbundle.
@ -49,7 +49,6 @@ jobs:
- name: Set up Flutter - name: Set up Flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
with: with:
flutter-version-file: pubspec.yaml
cache: true cache: true
# Decode and create the upload-keystore.jks file, used for signing the app. # Decode and create the upload-keystore.jks file, used for signing the app.
@ -85,7 +84,7 @@ jobs:
- name: Checkout Frosty repo - name: Checkout Frosty repo
uses: actions/checkout@v6 uses: actions/checkout@v6
# Always pick the newest production Xcode on the runner. # Always pick the newest production Xcode on the runner
- uses: maxim-lobanov/setup-xcode@v1 - uses: maxim-lobanov/setup-xcode@v1
with: with:
xcode-version: latest-stable xcode-version: latest-stable
@ -94,14 +93,13 @@ 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.3 ruby-version: 3.2
bundler-cache: true bundler-cache: true
# Setup and cache Flutter to install packages and build. # Setup and cache Flutter to install packages and build.
- name: Set up Flutter - name: Set up Flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
with: with:
flutter-version-file: pubspec.yaml
cache: true cache: true
# Setup the iOS project with environment variables without signing (building and signing will happen in fastlane). # Setup the iOS project with environment variables without signing (building and signing will happen in fastlane).

2
.gitignore vendored
View file

@ -44,8 +44,6 @@ 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/

View file

@ -1,9 +1,5 @@
{ {
"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"]

View file

@ -1,46 +1,55 @@
# AGENTS.md # AGENTS.md
Flutter Twitch client for iOS and Android. MobX with `build_runner`. The landing page is `web/` (own `AGENTS.md`). Flutter is pinned in `pubspec.yaml` `environment.flutter` — bump that one field; CI reads it via `flutter-version-file`. > **Monorepo note:** `web/` is a separate Next.js landing page with its own `AGENTS.md`. This file covers the Flutter app only.
## Workflow
- In plan mode, interview thoroughly — ask about UI/UX, tradeoffs, and edge cases before coding
- When new code supersedes existing functionality, find and remove everything it makes redundant
- Favor parallel tool calls and subagents when tasks are independent
## Commands ## Commands
```bash ```bash
flutter pub get flutter pub get # Install dependencies
flutter run --dart-define-from-file=.env # local; .env is gitignored flutter run --dart-define=clientId=ID --dart-define=secret=SECRET # Run with Twitch credentials
flutter run --dart-define=CLIENT_ID=ID --dart-define=SECRET=SECRET flutter analyze # Static analysis (run after changes)
flutter analyze flutter test # Run tests (if changes touch testable logic)
flutter test dart run build_runner build # Regenerate .g.dart files (MobX/JSON models)
flutter test test/path/to/file.dart dart run build_runner build --delete-conflicting-outputs # Same, but clears stale outputs
dart run build_runner build # after MobX / @JsonSerializable changes
``` ```
`--delete-conflicting-outputs` is ignored as of build_runner 2.15. Don't add it. ## Source Structure
## Layout - `lib/apis/` — API services (all extend `BaseApiClient`)
- `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
- `lib/apis/` — API clients extend `BaseApiClient` ## Architecture (non-obvious bits)
- `lib/models/` — data models; `@JsonSerializable` ones commit `.g.dart`
- `lib/screens/{feature}/` — screens; feature stores in co-located `stores/`
- `lib/stores/` — global stores only (`global_assets_store.dart`)
- `test/` mirrors `lib/`; shared fixtures in `test/fixtures/`
Don't add top-level store files or standalone API classes. Canonical MobX store: `lib/screens/settings/stores/auth_store.dart`. Extract state, reactions, and actions into stores when it keeps widgets on rendering. - Feature stores live in `lib/screens/{feature}/stores/` — don't create new top-level store files (only `lib/stores/` holds global stores)
- All API services extend `BaseApiClient` (`lib/apis/base_api_client.dart`) — don't create standalone API classes
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`. - MobX stores use a generated mixin pattern — see `lib/screens/settings/stores/auth_store.dart` for the canonical example
## Gotchas ## Gotchas
- Commit regenerated `.g.dart` files. Never edit them. CI fails the PR if they're stale. - 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.
- `main.dart` secure-storage `deleteAll()` looks redundant; it handles Android/iOS storage surviving uninstall. Don't remove it. - 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.
- `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. - Use package imports (`import 'package:frosty/...'`), not relative imports
- 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. - Always include trailing commas
- `android/gradle.properties`: keep `android.builtInKotlin=false` and `android.newDsl=false` so plugins that still apply KGP build on AGP 9. - Use single quotes
- `ios/Runner/AppDelegate.swift` is customized (`CookieExtractorPlugin` + `FlutterImplicitEngineDelegate`). Flutter will not auto-migrate UIScene; don't replace the delegate with the template.
- `android/.../MainActivity.kt` extends `PipCallbackHelperActivityWrapper` and registers `CookieExtractorPlugin` by hand. Don't revert it to `FlutterActivity`.
- Helix OAuth (`Bearer`) and the Twitch web GQL cookie are different tokens. Native playback uses the cookie; don't send the Helix token to GQL/Usher.
- `syncedChatDelay` is runtime-only (`includeFromJson/toJson: false`). Don't persist it with the other settings.
- Don't remove the `ALLOW_DEBUG_SIGNED_RELEASE` branch in `android/app/build.gradle.kts`. Real releases must still fail if `key.properties` is missing.
## Releases ## Testing
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. - `flutter test` runs all tests; `flutter test test/path/to/file.dart` for a single file
- HTTP mocking: `http_mock_adapter` (`DioAdapter`) — use full URLs in `onGet`/`onPost`
- General mocking: `mocktail` (no codegen required)
- Fixtures live in `test/fixtures/` (e.g., `irc_messages.dart`, `api_responses.dart`)
## Commits
Lowercase, no prefixes (e.g., `fix landscape bottom padding in chat bottom bar`). Keep commits tightly scoped.

View file

@ -1,61 +0,0 @@
# 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`.

View file

@ -16,9 +16,6 @@ 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

View file

@ -3,13 +3,13 @@ GEM
specs: specs:
CFPropertyList (3.0.8) CFPropertyList (3.0.8)
abbrev (0.1.2) abbrev (0.1.2)
addressable (2.9.0) addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0) public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17) artifactory (3.0.17)
atomos (0.1.3) atomos (0.1.3)
aws-eventstream (1.4.0) aws-eventstream (1.4.0)
aws-partitions (1.1260.0) aws-partitions (1.1200.0)
aws-sdk-core (3.252.0) aws-sdk-core (3.240.0)
aws-eventstream (~> 1, >= 1.3.0) aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0) aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9) aws-sigv4 (~> 1.9)
@ -17,19 +17,18 @@ GEM
bigdecimal bigdecimal
jmespath (~> 1, >= 1.6.1) jmespath (~> 1, >= 1.6.1)
logger logger
aws-sdk-kms (1.129.0) aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.248.0) aws-sdk-core (~> 3, >= 3.239.1)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.225.1) aws-sdk-s3 (1.209.0)
aws-sdk-core (~> 3, >= 3.248.0) aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1) aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4) babosa (1.0.4)
base64 (0.3.0) base64 (0.2.0)
benchmark (0.5.0) bigdecimal (4.0.1)
bigdecimal (4.1.2)
claide (1.1.0) claide (1.1.0)
colored (1.2) colored (1.2)
colored2 (3.1.2) colored2 (3.1.2)
@ -43,7 +42,7 @@ GEM
dotenv (2.8.1) dotenv (2.8.1)
emoji_regex (3.2.3) emoji_regex (3.2.3)
excon (0.112.0) excon (0.112.0)
faraday (1.10.5) faraday (1.10.4)
faraday-em_http (~> 1.0) faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0) faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1) faraday-excon (~> 1.1)
@ -68,20 +67,19 @@ GEM
faraday-net_http_persistent (1.2.0) faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0) faraday-patron (1.0.0)
faraday-rack (1.0.0) faraday-rack (1.0.0)
faraday-retry (1.0.4) faraday-retry (1.0.3)
faraday_middleware (1.2.1) faraday_middleware (1.2.1)
faraday (~> 1.0) faraday (~> 1.0)
fastimage (2.4.1) fastimage (2.4.0)
fastlane (2.234.0) fastlane (2.230.0)
CFPropertyList (>= 2.3, < 5.0.0) CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1) abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0) addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0) artifactory (~> 3.0)
aws-sdk-s3 (~> 1.197) aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0) babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2) base64 (~> 0.2.0)
benchmark (>= 0.1.0) bundler (>= 1.12.0, < 3.0.0)
bundler (>= 1.17.3, < 5.0.0)
colored (~> 1.2) colored (~> 1.2)
commander (~> 4.6) commander (~> 4.6)
csv (~> 3.3) csv (~> 3.3)
@ -92,11 +90,11 @@ GEM
faraday-cookie_jar (~> 0.0.6) faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0) faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0) fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.1.0) fastlane-sirp (>= 1.0.0)
gh_inspector (>= 1.1.2, < 2.0.0) gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3) google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1) google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, <= 2.1.1) google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-storage (~> 1.31) google-cloud-storage (~> 1.31)
highline (~> 2.0) highline (~> 2.0)
http-cookie (~> 1.0.5) http-cookie (~> 1.0.5)
@ -105,11 +103,10 @@ GEM
logger (>= 1.6, < 2.0) logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0) mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0) multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3) mutex_m (~> 0.3.0)
naturally (~> 2.2) naturally (~> 2.2)
nkf (~> 0.2) nkf (~> 0.2.0)
optparse (>= 0.1.1, < 1.0.0) optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0) plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0) rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5) security (= 0.1.5)
@ -122,42 +119,41 @@ GEM
xcodeproj (>= 1.13.0, < 2.0.0) xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1) xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.1.0) fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
gh_inspector (1.1.3) gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.102.0) google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.15.0, < 2.a) google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.18.0) google-apis-core (0.11.3)
addressable (~> 2.5, >= 2.5.1) addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9) googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.3, < 3.a) httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0) mini_mime (~> 1.0)
mutex_m
representable (~> 3.0) representable (~> 3.0)
retriable (>= 2.0, < 4.a) retriable (>= 2.0, < 4.a)
google-apis-iamcredentials_v1 (0.27.0) rexml
google-apis-core (>= 0.15.0, < 2.a) google-apis-iamcredentials_v1 (0.17.0)
google-apis-playcustomapp_v1 (0.17.0) google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (>= 0.15.0, < 2.a) google-apis-playcustomapp_v1 (0.13.0)
google-apis-storage_v1 (0.63.0) google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (>= 0.15.0, < 2.a) google-apis-storage_v1 (0.31.0)
google-cloud-core (1.9.0) google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a) google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0) google-cloud-errors (~> 1.0)
google-cloud-env (2.1.1) google-cloud-env (1.6.0)
faraday (>= 1.0, < 3.a) faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.6.0) google-cloud-errors (1.5.0)
google-cloud-storage (1.61.0) google-cloud-storage (1.47.0)
addressable (~> 2.8) addressable (~> 2.8)
digest-crc (~> 0.4) digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2) google-apis-iamcredentials_v1 (~> 0.1)
google-apis-iamcredentials_v1 (~> 0.18) google-apis-storage_v1 (~> 0.31.0)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6) google-cloud-core (~> 1.6)
googleauth (~> 1.9) googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0) mini_mime (~> 1.0)
googleauth (1.11.2) googleauth (1.8.1)
faraday (>= 1.0, < 3.a) faraday (>= 0.17.3, < 3.a)
google-cloud-env (~> 2.1)
jwt (>= 1.4, < 3.0) jwt (>= 1.4, < 3.0)
multi_json (~> 1.11) multi_json (~> 1.11)
os (>= 0.9, < 2.0) os (>= 0.9, < 2.0)
@ -168,41 +164,42 @@ GEM
httpclient (2.9.0) httpclient (2.9.0)
mutex_m mutex_m
jmespath (1.6.2) jmespath (1.6.2)
json (2.19.9) json (2.18.0)
jwt (2.10.3) jwt (2.10.2)
base64 base64
logger (1.7.0) logger (1.7.0)
mini_magick (4.13.2) mini_magick (4.13.2)
mini_mime (1.1.5) mini_mime (1.1.5)
multi_json (1.21.1) multi_json (1.19.1)
multipart-post (2.4.1) multipart-post (2.4.1)
mutex_m (0.3.0) mutex_m (0.3.0)
nanaimo (0.4.0) nanaimo (0.4.0)
naturally (2.3.0) naturally (2.3.0)
nkf (0.3.0) nkf (0.2.0)
optparse (0.8.1) optparse (0.8.1)
os (1.1.4) os (1.1.4)
ostruct (0.6.3)
plist (3.7.2) plist (3.7.2)
public_suffix (7.0.5) public_suffix (7.0.0)
rake (13.4.2) rake (13.3.1)
representable (3.2.0) representable (3.2.0)
declarative (< 0.1.0) declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0) trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0) uber (< 0.2.0)
retriable (3.8.0) retriable (3.1.2)
rexml (3.4.4) rexml (3.4.4)
rouge (3.28.0) rouge (3.28.0)
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
rubyzip (2.4.1) rubyzip (2.4.1)
security (0.1.5) security (0.1.5)
signet (0.22.0) signet (0.21.0)
addressable (~> 2.8) addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a) faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0) jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
simctl (1.6.10) simctl (1.6.10)
CFPropertyList CFPropertyList
naturally naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0) terminal-notifier (2.0.0)
terminal-table (3.0.2) terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3) unicode-display_width (>= 1.1.1, < 3)

View file

@ -6,6 +6,7 @@ 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")
} }
@ -26,6 +27,10 @@ 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
@ -45,26 +50,11 @@ android {
buildTypes { buildTypes {
release { release {
// Use the real upload key when key.properties is present (release signingConfig = signingConfigs.getByName("release")
// workflows create it from secrets). Allow debug signing only when a CI
// compile-only check explicitly opts in via ALLOW_DEBUG_SIGNED_RELEASE;
// otherwise fail loudly so a real release can never be silently
// debug-signed if the keystore step is ever missing.
signingConfig = when {
keystorePropertiesFile.exists() -> signingConfigs.getByName("release")
System.getenv("ALLOW_DEBUG_SIGNED_RELEASE") != null -> signingConfigs.getByName("debug")
else -> error("key.properties missing — set ALLOW_DEBUG_SIGNED_RELEASE=1 for compile-only builds")
}
} }
} }
} }
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter { flutter {
source = "../.." source = "../.."
} }

View file

@ -1,32 +0,0 @@
package com.tommychow.frosty
import android.webkit.CookieManager
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class CookieExtractorPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private lateinit var channel: MethodChannel
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(binding.binaryMessenger, "frosty/cookie_extractor")
channel.setMethodCallHandler(this)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
if (call.method == "extractTwitchAuthToken") {
val cookies = CookieManager.getInstance().getCookie("https://twitch.tv")
val authToken = cookies?.split(";")
?.map { it.trim() }
?.firstOrNull { it.startsWith("auth-token=") }
?.substringAfter("auth-token=")
result.success(authToken)
} else {
result.notImplemented()
}
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
}

View file

@ -1,11 +1,6 @@
package com.tommychow.frosty package com.tommychow.frosty
import cl.puntito.simple_pip_mode.PipCallbackHelperActivityWrapper import cl.puntito.simple_pip_mode.PipCallbackHelperActivityWrapper
import io.flutter.embedding.engine.FlutterEngine
class MainActivity: PipCallbackHelperActivityWrapper() { class MainActivity: PipCallbackHelperActivityWrapper() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(CookieExtractorPlugin())
}
} }

View file

@ -1,5 +1,3 @@
import com.android.build.api.variant.LibraryAndroidComponentsExtension
allprojects { allprojects {
repositories { repositories {
google() google()
@ -21,17 +19,6 @@ 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)
} }

View file

@ -17,14 +17,11 @@ 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
# Upload to the Play Store using the provided credentials and AAB from flutter build. # Upload to the play store ussing the provided credentials and AAB from flutter build.
# With dry_run:true (release dry-run workflow), validate_only authenticates and
# validates the AAB against Google Play without committing the release.
upload_to_play_store( upload_to_play_store(
json_key_data: Base64.decode64(ENV["PLAY_STORE_CREDENTIALS"]), json_key_data: Base64.decode64(ENV["PLAY_STORE_CREDENTIALS"]),
aab: "../build/app/outputs/bundle/release/app-release.aab", aab: "../build/app/outputs/bundle/release/app-release.aab"
validate_only: options[:dry_run] || false
) )
end end
end end

View file

@ -1 +1 @@
Watch live streams and see community emotes in chat. Watch and chat on Twitch with your favorite emotes from 7TV, BTTV, and FFZ!

View file

@ -1 +1 @@
Frosty Frosty for Twitch

View file

@ -2,7 +2,3 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m
android.useAndroidX=true android.useAndroidX=true
org.gradle.caching=true org.gradle.caching=true
org.gradle.parallel=true org.gradle.parallel=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false

View file

@ -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-9.3.1-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

View file

@ -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 "9.1.0" apply false id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false id("org.jetbrains.kotlin.android") version "2.2.20" 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

File diff suppressed because it is too large Load diff

View file

@ -1,28 +1,113 @@
## v5.3.1 ## v5.0.5
October 12, 2026 January 9, 2026
### Bug Fixes ### 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. - Fixed lag/frame drops in overlay chat mode.
- Fixed draggable divider still interactable in fullscreen mode.
- Fixed landscape bottom padding in chat bottom bar.
- [iOS] Fixed user agent breaking login.
--- ---
## v5.3.0 ## v5.0.4
October 12, 2026 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 ### New Features
- Added a Follow / Unfollow button under the player (between video and chat) for live and offline channels. - 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
## v5.2.9 - 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.
October 7, 2026 - 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 ### Bug Fixes
- Fixed tag stream lists reshuffling when loading more pages by paginating with Twitch’s sorted tag directory (GQL) instead of scanning Helix. - Fixed chat text input not unfocusing when tapping outside of it (by @zeykafx on GitHub).
- Fixed follow/unfollow failing with an integrity check error while logged in by confirming via a short Twitch WebView when needed. - 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)

View file

@ -1,35 +0,0 @@
# 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

View file

@ -1,45 +0,0 @@
# 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.

View file

@ -1,57 +0,0 @@
# 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`.

View file

@ -20,5 +20,7 @@
<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>

View file

@ -3,13 +3,13 @@ GEM
specs: specs:
CFPropertyList (3.0.8) CFPropertyList (3.0.8)
abbrev (0.1.2) abbrev (0.1.2)
addressable (2.9.0) addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0) public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17) artifactory (3.0.17)
atomos (0.1.3) atomos (0.1.3)
aws-eventstream (1.4.0) aws-eventstream (1.4.0)
aws-partitions (1.1260.0) aws-partitions (1.1200.0)
aws-sdk-core (3.252.0) aws-sdk-core (3.240.0)
aws-eventstream (~> 1, >= 1.3.0) aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0) aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9) aws-sigv4 (~> 1.9)
@ -17,19 +17,18 @@ GEM
bigdecimal bigdecimal
jmespath (~> 1, >= 1.6.1) jmespath (~> 1, >= 1.6.1)
logger logger
aws-sdk-kms (1.129.0) aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.248.0) aws-sdk-core (~> 3, >= 3.239.1)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.225.1) aws-sdk-s3 (1.209.0)
aws-sdk-core (~> 3, >= 3.248.0) aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1) aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4) babosa (1.0.4)
base64 (0.3.0) base64 (0.2.0)
benchmark (0.5.0) bigdecimal (4.0.1)
bigdecimal (4.1.2)
claide (1.1.0) claide (1.1.0)
colored (1.2) colored (1.2)
colored2 (3.1.2) colored2 (3.1.2)
@ -43,7 +42,7 @@ GEM
dotenv (2.8.1) dotenv (2.8.1)
emoji_regex (3.2.3) emoji_regex (3.2.3)
excon (0.112.0) excon (0.112.0)
faraday (1.10.5) faraday (1.10.4)
faraday-em_http (~> 1.0) faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0) faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1) faraday-excon (~> 1.1)
@ -68,20 +67,19 @@ GEM
faraday-net_http_persistent (1.2.0) faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0) faraday-patron (1.0.0)
faraday-rack (1.0.0) faraday-rack (1.0.0)
faraday-retry (1.0.4) faraday-retry (1.0.3)
faraday_middleware (1.2.1) faraday_middleware (1.2.1)
faraday (~> 1.0) faraday (~> 1.0)
fastimage (2.4.1) fastimage (2.4.0)
fastlane (2.234.0) fastlane (2.230.0)
CFPropertyList (>= 2.3, < 5.0.0) CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1) abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0) addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0) artifactory (~> 3.0)
aws-sdk-s3 (~> 1.197) aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0) babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2) base64 (~> 0.2.0)
benchmark (>= 0.1.0) bundler (>= 1.12.0, < 3.0.0)
bundler (>= 1.17.3, < 5.0.0)
colored (~> 1.2) colored (~> 1.2)
commander (~> 4.6) commander (~> 4.6)
csv (~> 3.3) csv (~> 3.3)
@ -92,11 +90,11 @@ GEM
faraday-cookie_jar (~> 0.0.6) faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0) faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0) fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.1.0) fastlane-sirp (>= 1.0.0)
gh_inspector (>= 1.1.2, < 2.0.0) gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3) google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1) google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, <= 2.1.1) google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-storage (~> 1.31) google-cloud-storage (~> 1.31)
highline (~> 2.0) highline (~> 2.0)
http-cookie (~> 1.0.5) http-cookie (~> 1.0.5)
@ -105,11 +103,10 @@ GEM
logger (>= 1.6, < 2.0) logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0) mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0) multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3) mutex_m (~> 0.3.0)
naturally (~> 2.2) naturally (~> 2.2)
nkf (~> 0.2) nkf (~> 0.2.0)
optparse (>= 0.1.1, < 1.0.0) optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0) plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0) rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5) security (= 0.1.5)
@ -122,42 +119,41 @@ GEM
xcodeproj (>= 1.13.0, < 2.0.0) xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1) xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.1.0) fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
gh_inspector (1.1.3) gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.102.0) google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.15.0, < 2.a) google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.18.0) google-apis-core (0.11.3)
addressable (~> 2.5, >= 2.5.1) addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9) googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.3, < 3.a) httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0) mini_mime (~> 1.0)
mutex_m
representable (~> 3.0) representable (~> 3.0)
retriable (>= 2.0, < 4.a) retriable (>= 2.0, < 4.a)
google-apis-iamcredentials_v1 (0.27.0) rexml
google-apis-core (>= 0.15.0, < 2.a) google-apis-iamcredentials_v1 (0.17.0)
google-apis-playcustomapp_v1 (0.17.0) google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (>= 0.15.0, < 2.a) google-apis-playcustomapp_v1 (0.13.0)
google-apis-storage_v1 (0.63.0) google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (>= 0.15.0, < 2.a) google-apis-storage_v1 (0.31.0)
google-cloud-core (1.9.0) google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a) google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0) google-cloud-errors (~> 1.0)
google-cloud-env (2.1.1) google-cloud-env (1.6.0)
faraday (>= 1.0, < 3.a) faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.6.0) google-cloud-errors (1.5.0)
google-cloud-storage (1.61.0) google-cloud-storage (1.47.0)
addressable (~> 2.8) addressable (~> 2.8)
digest-crc (~> 0.4) digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2) google-apis-iamcredentials_v1 (~> 0.1)
google-apis-iamcredentials_v1 (~> 0.18) google-apis-storage_v1 (~> 0.31.0)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6) google-cloud-core (~> 1.6)
googleauth (~> 1.9) googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0) mini_mime (~> 1.0)
googleauth (1.11.2) googleauth (1.8.1)
faraday (>= 1.0, < 3.a) faraday (>= 0.17.3, < 3.a)
google-cloud-env (~> 2.1)
jwt (>= 1.4, < 3.0) jwt (>= 1.4, < 3.0)
multi_json (~> 1.11) multi_json (~> 1.11)
os (>= 0.9, < 2.0) os (>= 0.9, < 2.0)
@ -168,41 +164,42 @@ GEM
httpclient (2.9.0) httpclient (2.9.0)
mutex_m mutex_m
jmespath (1.6.2) jmespath (1.6.2)
json (2.19.9) json (2.18.0)
jwt (2.10.3) jwt (2.10.2)
base64 base64
logger (1.7.0) logger (1.7.0)
mini_magick (4.13.2) mini_magick (4.13.2)
mini_mime (1.1.5) mini_mime (1.1.5)
multi_json (1.21.1) multi_json (1.19.1)
multipart-post (2.4.1) multipart-post (2.4.1)
mutex_m (0.3.0) mutex_m (0.3.0)
nanaimo (0.4.0) nanaimo (0.4.0)
naturally (2.3.0) naturally (2.3.0)
nkf (0.3.0) nkf (0.2.0)
optparse (0.8.1) optparse (0.8.1)
os (1.1.4) os (1.1.4)
ostruct (0.6.3)
plist (3.7.2) plist (3.7.2)
public_suffix (7.0.5) public_suffix (7.0.0)
rake (13.4.2) rake (13.3.1)
representable (3.2.0) representable (3.2.0)
declarative (< 0.1.0) declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0) trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0) uber (< 0.2.0)
retriable (3.8.0) retriable (3.1.2)
rexml (3.4.4) rexml (3.4.4)
rouge (3.28.0) rouge (3.28.0)
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
rubyzip (2.4.1) rubyzip (2.4.1)
security (0.1.5) security (0.1.5)
signet (0.22.0) signet (0.21.0)
addressable (~> 2.8) addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a) faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0) jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
simctl (1.6.10) simctl (1.6.10)
CFPropertyList CFPropertyList
naturally naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0) terminal-notifier (2.0.0)
terminal-table (3.0.2) terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3) unicode-display_width (>= 1.1.1, < 3)

View file

@ -1,27 +1,314 @@
PODS: PODS:
- advanced_in_app_review (0.0.1): - advanced_in_app_review (0.0.1):
- Flutter - Flutter
- app_links (7.0.0):
- Flutter
- device_info_plus (0.0.1):
- Flutter
- Firebase/CoreOnly (12.9.0):
- FirebaseCore (~> 12.9.0)
- Firebase/Crashlytics (12.9.0):
- Firebase/CoreOnly
- FirebaseCrashlytics (~> 12.9.0)
- Firebase/Performance (12.9.0):
- Firebase/CoreOnly
- FirebasePerformance (~> 12.9.0)
- firebase_analytics (12.1.3):
- firebase_core
- FirebaseAnalytics (= 12.9.0)
- Flutter
- firebase_core (4.5.0):
- Firebase/CoreOnly (= 12.9.0)
- Flutter
- firebase_crashlytics (5.0.8):
- Firebase/Crashlytics (= 12.9.0)
- firebase_core
- Flutter
- firebase_performance (0.11.1-5):
- Firebase/Performance (= 12.9.0)
- firebase_core
- Flutter
- FirebaseABTesting (12.9.0):
- FirebaseCore (~> 12.9.0)
- FirebaseAnalytics (12.9.0):
- FirebaseAnalytics/Default (= 12.9.0)
- FirebaseCore (~> 12.9.0)
- FirebaseInstallations (~> 12.9.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- FirebaseAnalytics/Default (12.9.0):
- FirebaseCore (~> 12.9.0)
- FirebaseInstallations (~> 12.9.0)
- GoogleAppMeasurement/Default (= 12.9.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- FirebaseCore (12.9.0):
- FirebaseCoreInternal (~> 12.9.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1)
- FirebaseCoreExtension (12.9.0):
- FirebaseCore (~> 12.9.0)
- FirebaseCoreInternal (12.9.0):
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- FirebaseCrashlytics (12.9.0):
- FirebaseCore (~> 12.9.0)
- FirebaseInstallations (~> 12.9.0)
- FirebaseRemoteConfigInterop (~> 12.9.0)
- FirebaseSessions (~> 12.9.0)
- GoogleDataTransport (~> 10.1)
- GoogleUtilities/Environment (~> 8.1)
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- FirebaseInstallations (12.9.0):
- FirebaseCore (~> 12.9.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- PromisesObjC (~> 2.4)
- FirebasePerformance (12.9.0):
- FirebaseCore (~> 12.9.0)
- FirebaseInstallations (~> 12.9.0)
- FirebaseRemoteConfig (~> 12.9.0)
- FirebaseSessions (~> 12.9.0)
- GoogleDataTransport (~> 10.1)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- nanopb (~> 3.30910.0)
- FirebaseRemoteConfig (12.9.0):
- FirebaseABTesting (~> 12.9.0)
- FirebaseCore (~> 12.9.0)
- FirebaseInstallations (~> 12.9.0)
- FirebaseRemoteConfigInterop (~> 12.9.0)
- FirebaseSharedSwift (~> 12.9.0)
- GoogleUtilities/Environment (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- FirebaseRemoteConfigInterop (12.9.0)
- FirebaseSessions (12.9.0):
- FirebaseCore (~> 12.9.0)
- FirebaseCoreExtension (~> 12.9.0)
- FirebaseInstallations (~> 12.9.0)
- GoogleDataTransport (~> 10.1)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- nanopb (~> 3.30910.0)
- PromisesSwift (~> 2.1)
- FirebaseSharedSwift (12.9.0)
- Flutter (1.0.0) - Flutter (1.0.0)
- flutter_native_splash (2.4.3):
- Flutter
- flutter_secure_storage (6.0.0): - flutter_secure_storage (6.0.0):
- Flutter - Flutter
- GoogleAdsOnDeviceConversion (3.2.0):
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/Core (12.9.0):
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/Default (12.9.0):
- GoogleAdsOnDeviceConversion (~> 3.2.0)
- GoogleAppMeasurement/Core (= 12.9.0)
- GoogleAppMeasurement/IdentitySupport (= 12.9.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/IdentitySupport (12.9.0):
- GoogleAppMeasurement/Core (= 12.9.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- GoogleDataTransport (10.1.0):
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- GoogleUtilities/AppDelegateSwizzler (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
- GoogleUtilities/Network
- GoogleUtilities/Privacy
- GoogleUtilities/Environment (8.1.0):
- GoogleUtilities/Privacy
- GoogleUtilities/Logger (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/MethodSwizzler (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilities/Network (8.1.0):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Privacy
- GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (8.1.0)":
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (8.1.0)
- GoogleUtilities/Reachability (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilities/UserDefaults (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- in_app_review (2.0.0):
- Flutter
- nanopb (3.30910.0):
- nanopb/decode (= 3.30910.0)
- nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- native_device_orientation (0.0.1):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- PromisesObjC (2.4.0)
- PromisesSwift (2.4.0):
- PromisesObjC (= 2.4.0)
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
- wakelock_plus (0.0.1):
- Flutter
- webview_flutter_wkwebview (0.0.1):
- Flutter
- FlutterMacOS
DEPENDENCIES: DEPENDENCIES:
- advanced_in_app_review (from `.symlinks/plugins/advanced_in_app_review/ios`) - advanced_in_app_review (from `.symlinks/plugins/advanced_in_app_review/ios`)
- app_links (from `.symlinks/plugins/app_links/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`)
- firebase_performance (from `.symlinks/plugins/firebase_performance/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
- native_device_orientation (from `.symlinks/plugins/native_device_orientation/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
SPEC REPOS:
trunk:
- Firebase
- FirebaseABTesting
- FirebaseAnalytics
- FirebaseCore
- FirebaseCoreExtension
- FirebaseCoreInternal
- FirebaseCrashlytics
- FirebaseInstallations
- FirebasePerformance
- FirebaseRemoteConfig
- FirebaseRemoteConfigInterop
- FirebaseSessions
- FirebaseSharedSwift
- GoogleAdsOnDeviceConversion
- GoogleAppMeasurement
- GoogleDataTransport
- GoogleUtilities
- nanopb
- PromisesObjC
- PromisesSwift
EXTERNAL SOURCES: EXTERNAL SOURCES:
advanced_in_app_review: advanced_in_app_review:
:path: ".symlinks/plugins/advanced_in_app_review/ios" :path: ".symlinks/plugins/advanced_in_app_review/ios"
app_links:
:path: ".symlinks/plugins/app_links/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
firebase_analytics:
:path: ".symlinks/plugins/firebase_analytics/ios"
firebase_core:
:path: ".symlinks/plugins/firebase_core/ios"
firebase_crashlytics:
:path: ".symlinks/plugins/firebase_crashlytics/ios"
firebase_performance:
:path: ".symlinks/plugins/firebase_performance/ios"
Flutter: Flutter:
:path: Flutter :path: Flutter
flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_secure_storage: flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios" :path: ".symlinks/plugins/flutter_secure_storage/ios"
in_app_review:
:path: ".symlinks/plugins/in_app_review/ios"
native_device_orientation:
:path: ".symlinks/plugins/native_device_orientation/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
SPEC CHECKSUMS: SPEC CHECKSUMS:
advanced_in_app_review: daebe4c801525b756fe589de60fd327cb12f965c advanced_in_app_review: fe8fd50b8670cb3dbe67e4c26ca643f7aea8dbb8
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47 app_links: 6d01271b3907b0ee7325c5297c75d697c4226c4d
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
Firebase: 065f2bb395062046623036d8e6dc857bc2521d56
firebase_analytics: b5c909bdc321fab0f9ddf8712f5cef79057e09fa
firebase_core: 8f0a8c3aca22e29012e419ee18109b4aa021f777
firebase_crashlytics: 4b21fc111f15e814eeab83f90a71efe3c1e9399a
firebase_performance: 0a30e8afaae656fdf6fa271ac3c62dc26b74d7a1
FirebaseABTesting: a399ffe546392a39b19a5c2fb28bd8ea178a6f47
FirebaseAnalytics: cd7d01d352f3c237c9a0e31552c257cd0b0c0352
FirebaseCore: 428912f751178b06bef0a1793effeb4a5e09a9b8
FirebaseCoreExtension: e911052d59cd0da237a45d706fc0f81654f035c1
FirebaseCoreInternal: b321eafae5362113bc182956fafc9922cfc77b72
FirebaseCrashlytics: 43913d587ef07beaf5db703baa61eacf9554658c
FirebaseInstallations: 7b64ffd006032b2b019a59b803858df5112d9eaa
FirebasePerformance: 94f614453614d8bb2a1a0177f3a1a6d2dbf4c504
FirebaseRemoteConfig: a2f6545e41551ffb520241d38b5d3d6776c9ebe8
FirebaseRemoteConfigInterop: 765ee19cd2bfa8e54937c8dae901eb634ad6787d
FirebaseSessions: a2d06fd980431fda934c7a543901aca05fc4edcc
FirebaseSharedSwift: 9d2fa84a46676302b89dbd5e6e62bce2fe376909
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
GoogleAdsOnDeviceConversion: d68c69dd9581a0f5da02617b6f377e5be483970f
GoogleAppMeasurement: fce7c1c90640d2f9f5c56771f71deacb2ba3f98c
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
in_app_review: 436034b18594851a7328d7f1c2ed5ec235b79cfc
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
native_device_orientation: 42487b19c552fb5a6a2b51e61b93366f628113df
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa
wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e
webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4
PODFILE CHECKSUM: 57c8aed26fba39d3ec9424816221f294a07c58eb PODFILE CHECKSUM: 57c8aed26fba39d3ec9424816221f294a07c58eb

View file

@ -11,13 +11,11 @@
3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E751BC99755AAC391DC0B29F /* Pods_Runner.framework */; }; 3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E751BC99755AAC391DC0B29F /* Pods_Runner.framework */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
F1C00CE01A2B3C4D00E5F6A8 /* CookieExtractorPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
BE8095795EAFF58C08A43B48 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A68609F67C30CFEF267CD4D3 /* GoogleService-Info.plist */; }; BE8095795EAFF58C08A43B48 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A68609F67C30CFEF267CD4D3 /* GoogleService-Info.plist */; };
D4F5A60712340001ABCD1234 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */; }; D4F5A60712340001ABCD1234 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */ /* Begin PBXCopyFilesBuildPhase section */
@ -40,7 +38,6 @@
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieExtractorPlugin.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
95D7E205EE04D01F6996ECFC /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; }; 95D7E205EE04D01F6996ECFC /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
@ -54,7 +51,6 @@
CFF3F6A0DA5A835B219C2D3C /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; }; CFF3F6A0DA5A835B219C2D3C /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; }; D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
E751BC99755AAC391DC0B29F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E751BC99755AAC391DC0B29F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -62,7 +58,6 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */, 3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -73,7 +68,6 @@
9740EEB11CF90186004384FC /* Flutter */ = { 9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */, 9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@ -112,7 +106,6 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */, D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */,
); );
@ -141,9 +134,6 @@
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
97C146ED1CF9000F007C117D /* Runner */ = { 97C146ED1CF9000F007C117D /* Runner */ = {
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = ( buildPhases = (
@ -170,9 +160,6 @@
/* Begin PBXProject section */ /* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = { 97C146E61CF9000F007C117D /* Project object */ = {
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
LastUpgradeCheck = 1510; LastUpgradeCheck = 1510;
@ -259,7 +246,7 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "# Symbol upload only matters for builds that ship; Debug dSYMs are absent or empty and fail the tool.\nif [ \"$CONFIGURATION\" = \"Debug\" ]; then\n echo \"Skipping Crashlytics symbol upload for Debug build\"\n exit 0\nfi\n# Locate upload-symbols whether Firebase is integrated via CocoaPods or Swift Package Manager.\nBIN=\"$PODS_ROOT/FirebaseCrashlytics/upload-symbols\"\n[ -f \"$BIN\" ] || BIN=\"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/upload-symbols\"\n\"$BIN\" --flutter-project \"$PROJECT_DIR/firebase_app_id_file.json\"\n"; shellScript = "\"$PODS_ROOT/FirebaseCrashlytics/upload-symbols\" --flutter-project \"$PROJECT_DIR/firebase_app_id_file.json\" ";
}; };
34CC65D58A1948B306772884 /* [CP] Embed Pods Frameworks */ = { 34CC65D58A1948B306772884 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
@ -317,7 +304,6 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
F1C00CE01A2B3C4D00E5F6A8 /* CookieExtractorPlugin.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -604,18 +590,6 @@
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
}; };
rootObject = 97C146E61CF9000F007C117D /* Project object */; rootObject = 97C146E61CF9000F007C117D /* Project object */;
} }

View file

@ -1,122 +0,0 @@
{
"pins" : [
{
"identity" : "abseil-cpp-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/abseil-cpp-binary.git",
"state" : {
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
"version" : "1.2024072200.0"
}
},
{
"identity" : "app-check",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git",
"state" : {
"revision" : "bb4002485ff867768dec13bf904a2ddb050bd1b1",
"version" : "11.3.0"
}
},
{
"identity" : "firebase-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : {
"revision" : "33a468adfdb75b53f05a37e7c886ca7c962b5c17",
"version" : "12.17.0"
}
},
{
"identity" : "google-ads-on-device-conversion-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : {
"revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64",
"version" : "3.6.0"
}
},
{
"identity" : "googleappmeasurement",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : {
"revision" : "fceaffa07d22dcd5624d3639fd970351a4a5ad8c",
"version" : "12.17.0"
}
},
{
"identity" : "googledatatransport",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git",
"state" : {
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
"version" : "10.1.0"
}
},
{
"identity" : "googleutilities",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "c46e5f8b7c23265f17c24ca7f9fa1b13ded7a822",
"version" : "8.1.1"
}
},
{
"identity" : "grpc-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/grpc-binary.git",
"state" : {
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
"version" : "1.69.1"
}
},
{
"identity" : "gtm-session-fetcher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : {
"revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a",
"version" : "5.3.0"
}
},
{
"identity" : "interop-ios-for-google-sdks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
"state" : {
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
"version" : "101.0.0"
}
},
{
"identity" : "leveldb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/leveldb.git",
"state" : {
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
"version" : "1.22.5"
}
},
{
"identity" : "nanopb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git",
"state" : {
"revision" : "3851d94a41890dea16dc3db34caf60e585cb4163",
"version" : "2.30910.1"
}
},
{
"identity" : "promises",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
"version" : "2.4.1"
}
}
],
"version" : 2
}

View file

@ -5,24 +5,6 @@
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"> buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries> <BuildActionEntries>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"

View file

@ -1,122 +0,0 @@
{
"pins" : [
{
"identity" : "abseil-cpp-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/abseil-cpp-binary.git",
"state" : {
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
"version" : "1.2024072200.0"
}
},
{
"identity" : "app-check",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git",
"state" : {
"revision" : "bb4002485ff867768dec13bf904a2ddb050bd1b1",
"version" : "11.3.0"
}
},
{
"identity" : "firebase-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : {
"revision" : "33a468adfdb75b53f05a37e7c886ca7c962b5c17",
"version" : "12.17.0"
}
},
{
"identity" : "google-ads-on-device-conversion-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : {
"revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64",
"version" : "3.6.0"
}
},
{
"identity" : "googleappmeasurement",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : {
"revision" : "fceaffa07d22dcd5624d3639fd970351a4a5ad8c",
"version" : "12.17.0"
}
},
{
"identity" : "googledatatransport",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git",
"state" : {
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
"version" : "10.1.0"
}
},
{
"identity" : "googleutilities",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "c46e5f8b7c23265f17c24ca7f9fa1b13ded7a822",
"version" : "8.1.1"
}
},
{
"identity" : "grpc-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/grpc-binary.git",
"state" : {
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
"version" : "1.69.1"
}
},
{
"identity" : "gtm-session-fetcher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : {
"revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a",
"version" : "5.3.0"
}
},
{
"identity" : "interop-ios-for-google-sdks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
"state" : {
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
"version" : "101.0.0"
}
},
{
"identity" : "leveldb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/leveldb.git",
"state" : {
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
"version" : "1.22.5"
}
},
{
"identity" : "nanopb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git",
"state" : {
"revision" : "3851d94a41890dea16dc3db34caf60e585cb4163",
"version" : "2.30910.1"
}
},
{
"identity" : "promises",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
"version" : "2.4.1"
}
}
],
"version" : 2
}

View file

@ -2,23 +2,12 @@ import Flutter
import UIKit import UIKit
@main @main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { @objc class AppDelegate: FlutterAppDelegate {
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
guard let cookieExtractorRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "CookieExtractorPlugin"
) else {
assertionFailure("Failed to create CookieExtractorPlugin registrar")
return
}
CookieExtractorPlugin.register(with: cookieExtractorRegistrar)
}
} }

View file

@ -1,27 +0,0 @@
import Flutter
import WebKit
class CookieExtractorPlugin: NSObject, FlutterPlugin {
static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "frosty/cookie_extractor",
binaryMessenger: registrar.messenger()
)
let instance = CookieExtractorPlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
}
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
if call.method == "extractTwitchAuthToken" {
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
let authCookie = cookies.first {
$0.name == "auth-token" &&
($0.domain == "twitch.tv" || $0.domain.hasSuffix(".twitch.tv"))
}
result(authCookie?.value)
}
} else {
result(FlutterMethodNotImplemented)
}
}
}

View file

@ -52,32 +52,7 @@
<true/> <true/>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIStatusBarHidden</key> <key>UIStatusBarHidden</key>
<false/> <false/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
</dict> </dict>
</plist> </plist>

View file

@ -17,9 +17,7 @@ default_platform(:ios)
platform :ios do platform :ios do
desc 'Push a new release build to the App Store' desc 'Push a new release build to the App Store'
lane :release do |options| lane :release do
dry_run = options[:dry_run] || false
# Setup for CI. Uses a keychain to prevent the CI from hanging due to password prompts. # Setup for CI. Uses a keychain to prevent the CI from hanging due to password prompts.
setup_ci setup_ci
@ -31,9 +29,8 @@ platform :ios do
is_key_content_base64: true is_key_content_base64: true
) )
# Manage code signing (with match). readonly on a dry run so we only verify the # Manage code signing (with match).
# existing cert/profile resolve and never regenerate or mutate the certificates repo. sync_code_signing
sync_code_signing(readonly: dry_run)
# Automatic signing is used during development, but fastlane needs to use manual signing. # Automatic signing is used during development, but fastlane needs to use manual signing.
update_code_signing_settings( update_code_signing_settings(
@ -48,11 +45,6 @@ platform :ios do
output_directory: '../build/ios/archive' output_directory: '../build/ios/archive'
) )
# On a dry run, stop here: the signed archive above exercises every step that
# actually fails (ASC key, match certs/profiles, signing, release build). The
# App Store upload itself is the only remaining unverified step.
next if dry_run
# Upload the app to the App Store. # Upload the app to the App Store.
upload_to_app_store( upload_to_app_store(
submit_for_review: true, submit_for_review: true,

View file

@ -1 +1 @@
Frosty Frosty for Twitch

View file

@ -1 +1 @@
Watch live streams and see community emotes in chat. Watch and chat on Twitch with your favorite emotes from 7TV, BTTV, and FFZ!

View file

@ -1 +1 @@
Community emotes in chat See 7TV, BTTV, and FFZ emotes!

View file

@ -44,13 +44,6 @@ 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;
@ -168,8 +161,7 @@ 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.',
@ -210,8 +202,6 @@ 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.';
} }

View file

@ -42,7 +42,7 @@ class FFZApi extends BaseApiClient {
); );
} }
/// Returns a map of user IDs to a list of their FFZ badges. /// Returns a map of badges 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');

View file

@ -1,163 +0,0 @@
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';

View file

@ -145,29 +145,17 @@ class TwitchApi extends BaseApiClient {
return data['access_token'] as String; return data['access_token'] as String;
} }
/// Validates [token] with Twitch. /// Returns a bool indicating the validity of the given token.
/// 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 {
final data = await get<JsonMap>( await get<JsonMap>(
'$_oauthBaseUrl/validate', '$_oauthBaseUrl/validate',
headers: {'Authorization': 'Bearer $token'}, headers: {'Authorization': 'Bearer $token'},
); );
final scopes = data['scopes']; return true;
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 null; return false;
} 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');
@ -175,23 +163,11 @@ class TwitchApi extends BaseApiClient {
} }
} }
/// Returns a [StreamsTwitch] object that contains top streams and a cursor for further requests. /// Returns a [StreamsTwitch] object that contains the top 20 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: queryParams, queryParameters: cursor != null ? {'after': cursor} : null,
); );
return StreamsTwitch.fromJson(data); return StreamsTwitch.fromJson(data);
@ -202,7 +178,7 @@ class TwitchApi extends BaseApiClient {
required String id, required String id,
String? cursor, String? cursor,
}) async { }) async {
final queryParams = {'user_id': id, 'first': '100'}; final queryParams = {'user_id': id};
if (cursor != null) queryParams['after'] = cursor; if (cursor != null) queryParams['after'] = cursor;
final data = await get<JsonMap>( final data = await get<JsonMap>(
@ -218,7 +194,7 @@ class TwitchApi extends BaseApiClient {
required String userId, required String userId,
String? cursor, String? cursor,
}) async { }) async {
final queryParams = {'user_id': userId, 'first': '100'}; final queryParams = {'user_id': userId, 'first': '20'};
if (cursor != null) queryParams['after'] = cursor; if (cursor != null) queryParams['after'] = cursor;
final data = await get<JsonMap>( final data = await get<JsonMap>(
@ -229,153 +205,19 @@ 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 = <String, dynamic>{ final queryParams = {'game_id': gameId};
'game_id': gameId, if (cursor != null) queryParams['after'] = cursor;
'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>(
@ -623,131 +465,4 @@ class TwitchApi extends BaseApiClient {
return data['messages'] as JsonList; return data['messages'] as JsonList;
} }
/// Returns a list of broadcaster IDs for channels the authenticated user
/// moderates, following pagination so moderators of more than one page of
/// channels aren't silently truncated at the first 100.
Future<List<String>> getModeratedChannels({
required String id,
String? cursor,
}) async {
try {
// Default page size is 20; request the max to keep the request count low.
final queryParams = {'user_id': id, 'first': '100'};
if (cursor != null) queryParams['after'] = cursor;
final data = await get<JsonMap>(
'/moderation/channels',
queryParameters: queryParams,
);
final channels = data['data'] as JsonList;
final result = channels
.map((channel) => (channel as JsonMap)['broadcaster_id'] as String?)
.whereType<String>()
.toList();
final paginationCursor = data['pagination']['cursor'];
if (paginationCursor != null && channels.isNotEmpty) {
// Throttle between pages to stay under Twitch's rate limit, matching
// getUserBlockedList above.
await Future.delayed(const Duration(milliseconds: 150));
result.addAll(
await getModeratedChannels(id: id, cursor: paginationCursor),
);
}
return result;
} on ApiException catch (e) {
// Backstop: callers should skip this without moderator scopes, but a
// stale/partial token must not take down chat.
debugPrint('Failed to get moderated channels: $e');
return [];
}
}
/// Deletes a specific chat message.
Future<bool> deleteChatMessage({
required String broadcasterId,
required String moderatorId,
required String messageId,
}) async {
// Let ApiException propagate so the caller can surface Twitch's reason
// (e.g. you can't delete the broadcaster's or another moderator's message)
// instead of a generic failure.
await delete<dynamic>(
'/moderation/chat',
queryParameters: {
'broadcaster_id': broadcasterId,
'moderator_id': moderatorId,
'message_id': messageId,
},
);
return true;
}
/// Bans or times out a user from a channel.
///
/// The optional `duration` in seconds will timeout the user. If omitted, the user is banned.
/// The optional `reason` will be displayed to the banned user and other moderators.
Future<bool> banUser({
required String broadcasterId,
required String moderatorId,
required String userIdToBan,
int? duration,
String? reason,
}) async {
final requestBody = {
'data': {
'user_id': userIdToBan,
'duration': ?duration,
'reason': ?reason,
},
};
// Let ApiException propagate so the caller can surface Twitch's reason
// (e.g. the target is a moderator and can't be banned) instead of a
// generic failure.
final response = await post<JsonMap>(
'/moderation/bans',
data: requestBody,
queryParameters: {
'broadcaster_id': broadcasterId,
'moderator_id': moderatorId,
},
);
final data = response['data'] as JsonList;
return data.isNotEmpty;
}
/// Removes a ban or timeout for a user in a channel.
///
/// A timeout is a time-limited ban, so this lifts both. Lets ApiException
/// propagate (e.g. the user isn't currently banned) so the caller can show
/// Twitch's reason.
Future<bool> unbanUser({
required String broadcasterId,
required String moderatorId,
required String userId,
}) async {
await delete<dynamic>(
'/moderation/bans',
queryParameters: {
'broadcaster_id': broadcasterId,
'moderator_id': moderatorId,
'user_id': userId,
},
);
return true;
}
}
/// Result of a successful Twitch `/oauth2/validate` call.
class TwitchTokenInfo {
final List<String> scopes;
/// Present for user access tokens; omitted for app access tokens.
final String? userId;
const TwitchTokenInfo({required this.scopes, this.userId});
} }

View file

@ -1,559 +0,0 @@
import 'dart:math';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/apis/twitch_integrity.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/models/playback_access_token.dart';
import 'package:frosty/models/stream.dart';
/// Viewer sort for GQL directory / tag stream lists.
enum StreamViewerSort {
viewersDesc,
viewersAsc;
String get gqlValue => switch (this) {
StreamViewerSort.viewersDesc => 'VIEWER_COUNT',
StreamViewerSort.viewersAsc => 'VIEWER_COUNT_ASC',
};
}
/// Twitch GQL API client for playback tokens and directory queries
/// (freeform tags + sorted streams) that Helix does not expose.
///
/// Uses the Twitch web client ID (not the app's registered client ID)
/// since the GQL endpoint requires it.
class TwitchGqlApi extends BaseApiClient {
TwitchGqlApi(Dio dio) : super(dio, 'https://gql.twitch.tv');
static const _gqlHeaders = <String, String>{
'Client-ID': twitchGqlClientId,
'Content-Type': 'application/json',
};
Future<JsonMap> _gql(
Map<String, Object?> body, {
Map<String, String>? extraHeaders,
}) async {
final response = await post<JsonMap>(
'/gql',
data: body,
headers: {..._gqlHeaders, ...?extraHeaders},
);
final errors = response['errors'];
if (errors is List && errors.isNotEmpty) {
final message = (errors.first as JsonMap)['message'] as String? ??
'Twitch GQL error';
if (message.toLowerCase().contains('integrity')) {
throw IntegrityCheckException(message);
}
final data = response['data'];
final dataEmpty = data == null ||
(data is Map &&
data.values.every((value) => value == null));
if (dataEmpty) {
throw ApiException(message);
}
}
return response;
}
/// Optional web-session headers for directory pagination (page-2+).
Map<String, String> _directoryHeaders({
String? authToken,
String? integrityToken,
String? deviceId,
}) {
return {
'User-Agent': playbackUserAgent,
if (authToken != null && authToken.isNotEmpty)
'Authorization': 'OAuth $authToken',
if (deviceId != null && deviceId.isNotEmpty) 'X-Device-Id': deviceId,
if (integrityToken != null && integrityToken.isNotEmpty)
'Client-Integrity': integrityToken,
};
}
/// Browser-consistent User-Agent for playback-related requests. Twitch has
/// rejected access-token requests with non-browser UAs before
/// (streamlink#6574); sending one consistent with the web client ID and
/// web session token avoids the obvious fingerprint mismatch.
static const playbackUserAgent =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
/// Headers for the HLS playlist/segment requests issued by the native
/// player, mirroring what the web player sends to Usher/weaver.
static const playbackHttpHeaders = <String, String>{
'User-Agent': playbackUserAgent,
'Referer': 'https://player.twitch.tv',
'Origin': 'https://player.twitch.tv',
};
/// Fetches a playback access token for the given channel [login].
///
/// When [authToken] is provided, the request is authenticated which may
/// grant ad-free playback for subscribers and Turbo users.
///
/// [playerType] / [platform] mirror the web player's GQL variables. Ad
/// blocking (TwitchAdSolutions / vaft) uses non-`site` player types such as
/// `popout` / `embed` / `autoplay`.
///
/// Uses the web client's persisted GQL query (smaller payload, blends in
/// with regular web traffic); falls back to the inline query template if
/// Twitch ever rotates the persisted-query hash.
Future<PlaybackAccessToken> getPlaybackAccessToken({
required String login,
String? authToken,
String playerType = 'site',
String platform = 'web',
}) async {
final headers = <String, String>{
'Client-ID': twitchGqlClientId,
'User-Agent': playbackUserAgent,
if (authToken != null) 'Authorization': 'OAuth $authToken',
};
final variables = {
'isLive': true,
'login': login,
'isVod': false,
'vodID': '',
'playerType': playerType,
'platform': platform,
};
Future<JsonMap> query(Map<String, Object> body) =>
post<JsonMap>('/gql', data: body, headers: headers);
final response = await query({
'operationName': 'PlaybackAccessToken',
'extensions': {
'persistedQuery': {
'version': 1,
// Stable for years; used by the web player and streamlink.
'sha256Hash':
'ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9',
},
},
'variables': variables,
});
// PersistedQueryNotFound (or similar) comes back as a 200 with an
// errors array — retry once with the inline template before failing.
if (response['errors'] != null && response['data'] == null) {
debugPrint(
'TwitchGqlApi: persisted query failed (${response['errors']}), '
'falling back to inline template',
);
return PlaybackAccessToken.fromGqlResponse(
await query({
'operationName': 'PlaybackAccessToken_Template',
'query':
'query PlaybackAccessToken_Template(\$login: String!, \$isLive: Boolean!, \$vodID: ID!, \$isVod: Boolean!, \$playerType: String!) { streamPlaybackAccessToken(channelName: \$login, params: {platform: "web", playerBackend: "mediaplayer", playerType: \$playerType}) @include(if: \$isLive) { value signature __typename } videoPlaybackAccessToken(id: \$vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: \$playerType}) @include(if: \$isVod) { value signature __typename } }',
'variables': variables,
}),
);
}
return PlaybackAccessToken.fromGqlResponse(response);
}
/// Fetches raw playlist text (multivariant or media) with playback headers.
Future<String> fetchPlaylistText(String url) async {
return get<String>(url, headers: playbackHttpHeaders);
}
/// Builds the HLS stream URL for the given channel [login] and [token].
String buildHlsUrl({
required String login,
required PlaybackAccessToken token,
}) {
final random = Random().nextInt(999999);
final encodedToken = Uri.encodeComponent(token.value);
// Usher v2 — what the web player and streamlink use today. Same query
// params and token; the multivariant playlist is standards-compliant
// (EXT-X-SESSION-DATA + STREAM-INF only, no EXT-X-MEDIA/TWITCH-INFO).
return 'https://usher.ttvnw.net/api/v2/channel/hls/$login.m3u8'
'?sig=${token.signature}'
'&token=$encodedToken'
'&allow_source=true'
'&allow_audio_only=true'
'&fast_bread=true'
'&supported_codecs=av1,h265,h264'
'&reassignments_supported=true'
'&platform=web'
'&player_backend=mediaplayer'
'&play_session_id=${_randomSessionId()}'
'&playlist_include_framerate=true'
'&p=$random';
}
/// 32-char hex play-session id, matching the web client and streamlink. Sent
/// to Usher to identify the playback session; Usher tolerates its absence
/// today but has historically tightened required params.
String _randomSessionId() {
final random = Random();
final buffer = StringBuffer();
for (var i = 0; i < 32; i++) {
buffer.write(random.nextInt(16).toRadixString(16));
}
return buffer.toString();
}
/// Freeform tag name suggestions for search-as-you-type.
Future<List<String>> searchFreeformTags({
required String query,
int first = 20,
}) async {
if (query.trim().isEmpty) return const [];
final response = await _gql({
'operationName': 'SearchFreeformTags',
'query':
r'query SearchFreeformTags($query: String!, $first: Int) {'
r' searchFreeformTags(userQuery: $query, first: $first) {'
r' edges { node { tagName } } } }',
'variables': {'query': query, 'first': first},
});
final edges =
(((response['data'] as JsonMap?)?['searchFreeformTags']
as JsonMap?)?['edges']
as List?) ??
const [];
return edges
.map((edge) {
final node = (edge as JsonMap)['node'] as JsonMap?;
return node?['tagName'] as String?;
})
.whereType<String>()
.toList();
}
/// Live streams matching optional [freeformTags] / [languages], sorted by viewers.
///
/// Twitch caps `first` at 30 for this connection. Cursor pagination needs a
/// Client-Integrity token (+ matching [deviceId] / web [authToken]); without
/// them page-2 throws [IntegrityCheckException].
Future<StreamsTwitch> getStreams({
List<String>? freeformTags,
List<String>? languages,
StreamViewerSort sort = StreamViewerSort.viewersDesc,
String? cursor,
int first = 30,
String? authToken,
String? integrityToken,
String? deviceId,
}) async {
final cappedFirst = first.clamp(1, 30);
final gqlLangs = languages == null || languages.isEmpty
? null
: languages
.map((l) => l.trim().toUpperCase())
.where((l) => l.isNotEmpty)
.toList();
final response = await _gql(
{
'operationName': 'FrostyTopStreams',
'query':
r'query FrostyTopStreams($first: Int, $after: Cursor, $tags: [String!], $sort: StreamSort, $langs: [Language!]) {'
r' streams(first: $first, after: $after, options: { sort: $sort, freeformTags: $tags, languages: $langs }) {'
r' edges { cursor node {'
r' id title viewersCount createdAt language'
r' game { id name }'
r' broadcaster { id login displayName }'
r' } } pageInfo { hasNextPage } } }',
'variables': {
'first': cappedFirst,
'after': cursor,
'tags': freeformTags,
'sort': sort.gqlValue,
'langs': gqlLangs,
},
},
extraHeaders: _directoryHeaders(
authToken: authToken,
integrityToken: integrityToken,
deviceId: deviceId,
),
);
final connection =
(response['data'] as JsonMap?)?['streams'] as JsonMap? ?? const {};
return _streamsFromConnection(connection);
}
/// Live streams under a category/game, with viewer sort and optional languages.
Future<StreamsTwitch> getGameStreams({
required String gameId,
List<String>? languages,
StreamViewerSort sort = StreamViewerSort.viewersDesc,
String? cursor,
int first = 30,
String? authToken,
String? integrityToken,
String? deviceId,
}) async {
final cappedFirst = first.clamp(1, 30);
final gqlLangs = languages == null || languages.isEmpty
? null
: languages
.map((l) => l.trim().toUpperCase())
.where((l) => l.isNotEmpty)
.toList();
final response = await _gql(
{
'operationName': 'FrostyGameStreams',
'query':
r'query FrostyGameStreams($id: ID!, $first: Int, $after: Cursor, $sort: StreamSort, $langs: [Language!]) {'
r' game(id: $id) { streams(first: $first, after: $after, options: { sort: $sort, languages: $langs }) {'
r' edges { cursor node {'
r' id title viewersCount createdAt language'
r' game { id name }'
r' broadcaster { id login displayName }'
r' } } pageInfo { hasNextPage } } } }',
'variables': {
'id': gameId,
'first': cappedFirst,
'after': cursor,
'sort': sort.gqlValue,
'langs': gqlLangs,
},
},
extraHeaders: _directoryHeaders(
authToken: authToken,
integrityToken: integrityToken,
deviceId: deviceId,
),
);
final game = (response['data'] as JsonMap?)?['game'] as JsonMap?;
if (game == null) {
return const StreamsTwitch([], {});
}
return _streamsFromConnection(game['streams'] as JsonMap? ?? const {});
}
/// Persisted hashes used by the Twitch web Follow button (same as Xtra).
static const _followUserHash =
'800e7346bdf7e5278a3c1d3f21b2b56e2639928f86815677a7126b093b2fdd08';
static const _unfollowUserHash =
'f7dae976ebf41c755ae2d758546bfd176b4eeb856656098bb40e0a672ca0d880';
/// Follows [targetId] using the web GQL session. Helix no longer supports this.
///
/// Requires [authToken] from the Twitch web cookie (`gqlToken`). Some accounts
/// need a [integrityToken] + matching [deviceId]; callers should retry after
/// [fetchIntegrityToken] when [IntegrityCheckException] is thrown.
Future<void> followUser({
required String targetId,
required String authToken,
bool disableNotifications = false,
String? integrityToken,
String? deviceId,
}) async {
await _mutateFollow(
operationName: 'FollowButton_FollowUser',
sha256Hash: _followUserHash,
authToken: authToken,
integrityToken: integrityToken,
deviceId: deviceId,
variables: {
'input': {
'disableNotifications': disableNotifications,
'targetID': targetId,
},
},
successPath: const ['followUser'],
);
}
/// Unfollows [targetId] using the web GQL session.
Future<void> unfollowUser({
required String targetId,
required String authToken,
String? integrityToken,
String? deviceId,
}) async {
await _mutateFollow(
operationName: 'FollowButton_UnfollowUser',
sha256Hash: _unfollowUserHash,
authToken: authToken,
integrityToken: integrityToken,
deviceId: deviceId,
variables: {
'input': {'targetID': targetId},
},
successPath: const ['unfollowUser'],
);
}
/// Mints a Client-Integrity token for authenticated GQL mutations.
///
/// Bound to [deviceId] and [authToken]; send the same values on follow calls.
/// Tokens minted outside a real browser are usually flagged `is_bad_bot` —
/// those throw [IntegrityCheckException] so callers can fall back to a
/// WebView capture.
Future<({String token, int expirationMs})> fetchIntegrityToken({
required String authToken,
required String deviceId,
}) async {
final response = await post<JsonMap>(
'/integrity',
data: <String, dynamic>{},
headers: {
'Client-ID': twitchGqlClientId,
'Authorization': 'OAuth $authToken',
'X-Device-Id': deviceId,
'User-Agent': playbackUserAgent,
'Content-Type': 'application/json',
},
);
final token = response['token'] as String?;
final expiration = response['expiration'];
if (token == null || token.isEmpty) {
throw const ApiException('Twitch integrity token missing from response');
}
if (TwitchIntegrity.isBadBot(token)) {
throw const IntegrityCheckException(
'Twitch integrity token flagged as bad bot',
);
}
final expirationMs = expiration is int
? expiration
: int.tryParse('$expiration') ??
TwitchIntegrity.expirationMs(token) ??
0;
return (token: token, expirationMs: expirationMs);
}
Future<void> _mutateFollow({
required String operationName,
required String sha256Hash,
required String authToken,
required JsonMap variables,
required List<String> successPath,
String? integrityToken,
String? deviceId,
}) async {
final headers = <String, String>{
'Client-ID': twitchGqlClientId,
'Content-Type': 'application/json',
'Authorization': 'OAuth $authToken',
'User-Agent': playbackUserAgent,
if (deviceId != null && deviceId.isNotEmpty) 'X-Device-Id': deviceId,
if (integrityToken != null && integrityToken.isNotEmpty)
'Client-Integrity': integrityToken,
};
final response = await post<dynamic>(
'/gql',
data: {
'operationName': operationName,
'variables': variables,
'extensions': {
'persistedQuery': {
'version': 1,
'sha256Hash': sha256Hash,
},
},
},
headers: headers,
);
final map = _gqlPayloadMap(response);
_throwIfGqlFailed(map);
JsonMap? cursor = map['data'] as JsonMap?;
for (final key in successPath) {
final next = cursor?[key];
if (next is JsonMap) {
cursor = next;
} else if (next == null) {
throw ApiException('Twitch $operationName returned no data');
} else {
return;
}
}
}
JsonMap _gqlPayloadMap(dynamic response) {
if (response is List) {
if (response.isEmpty || response.first is! Map) {
throw const ApiException('Unexpected Twitch GQL response');
}
return Map<String, dynamic>.from(response.first as Map);
}
if (response is Map) {
return Map<String, dynamic>.from(response);
}
throw const ApiException('Unexpected Twitch GQL response');
}
void _throwIfGqlFailed(JsonMap map) {
final errors = map['errors'];
if (errors is! List || errors.isEmpty) return;
final message =
(errors.first as JsonMap)['message'] as String? ?? 'Twitch GQL error';
final lower = message.toLowerCase();
if (lower.contains('integrity')) {
throw IntegrityCheckException(message);
}
throw ApiException(message);
}
StreamsTwitch _streamsFromConnection(JsonMap connection) {
final edges = (connection['edges'] as List?) ?? const [];
final pageInfo = connection['pageInfo'] as JsonMap?;
// Prefer explicit pageInfo; if missing, assume more when we got a full page.
final hasNext = pageInfo?['hasNextPage'] as bool? ?? edges.length >= 30;
final streams = <StreamTwitch>[];
String? nextCursor;
for (final edge in edges) {
final edgeMap = edge as JsonMap;
nextCursor = edgeMap['cursor'] as String?;
final node = edgeMap['node'] as JsonMap?;
if (node == null) continue;
streams.add(_streamFromNode(node));
}
return StreamsTwitch(
streams,
hasNext && nextCursor != null && nextCursor.isNotEmpty
? {'cursor': nextCursor}
: const {},
);
}
StreamTwitch _streamFromNode(JsonMap node) {
final broadcaster = node['broadcaster'] as JsonMap? ?? const {};
final game = node['game'] as JsonMap? ?? const {};
final login = broadcaster['login'] as String? ?? '';
final language = (node['language'] as String? ?? '').toLowerCase();
return StreamTwitch(
broadcaster['id'] as String? ?? '',
login,
broadcaster['displayName'] as String? ?? login,
game['id'] as String? ?? '',
game['name'] as String? ?? '',
node['title'] as String? ?? '',
(node['viewersCount'] as num?)?.toInt() ?? 0,
node['createdAt'] as String? ?? '',
// Helix-style placeholder so existing thumbnail widgets keep working.
'https://static-cdn.jtvnw.net/previews-ttv/live_user_$login-{width}x{height}.jpg',
language,
const [],
);
}
}

View file

@ -1,81 +0,0 @@
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,
});
}

View file

@ -3,7 +3,6 @@ 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';
@ -15,66 +14,16 @@ class UnauthorizedInterceptor extends Interceptor {
@override @override
void onError(DioException err, ErrorInterceptorHandler handler) { void onError(DioException err, ErrorInterceptorHandler handler) {
// Check if this is a 401 Unauthorized error on a Twitch Helix/OAuth request // Check if this is a 401 Unauthorized error
if (err.response?.statusCode == 401 && if (err.response?.statusCode == 401) {
_isTwitchHelixOrOAuth(err.requestOptions.uri)) {
// For token validation requests, let the error propagate so validateToken can handle it // For token validation requests, let the error propagate so validateToken can handle it
if (err.requestOptions.uri.path.endsWith('/validate')) { if (err.requestOptions.uri.path.endsWith('/validate')) {
handler.next(err); handler.next(err);
return; return;
} }
final path = err.requestOptions.uri.path; // For other requests, show login dialog and reject so the Future completes
_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;
} }
@ -83,20 +32,25 @@ class UnauthorizedInterceptor extends Interceptor {
handler.next(err); handler.next(err);
} }
void _showAuthDialog({ void _showLoginDialog() {
required String title, // Use the global navigator key to show dialog without needing BuildContext
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, barrierDismissible: false, // User must choose an action
builder: (BuildContext dialogContext) { builder: (BuildContext dialogContext) {
return FrostyDialog( return FrostyDialog(
title: title, title: title,
@ -105,39 +59,34 @@ class UnauthorizedInterceptor extends Interceptor {
TextButton( TextButton(
onPressed: () { onPressed: () {
_isDialogShowing = false; _isDialogShowing = false;
Navigator.of(dialogContext).pop(); Navigator.of(dialogContext).pop(); // Close dialog
}, },
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
FilledButton( FilledButton(
onPressed: () { onPressed: () {
_isDialogShowing = false; _isDialogShowing = false;
Navigator.of(dialogContext).pop(); Navigator.of(dialogContext).pop(); // Close dialog
onPrimary(); // Navigate to login WebView
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => Provider<AuthStore>.value(
value: _authStore,
child: const LoginWebView(),
),
),
);
}, },
child: Text(primaryLabel), child: Text('Log in'),
), ),
], ],
); );
}, },
).then((_) { ).then((_) {
// Reset flag when dialog is dismissed
_isDialogShowing = false; _isDialogShowing = false;
}); });
} }
static bool _isDialogShowing = false; static bool _isDialogShowing = false;
bool _isTwitchHelixOrOAuth(Uri uri) {
final url = uri.toString();
return url.startsWith('https://api.twitch.tv/helix') ||
url.startsWith('https://id.twitch.tv/oauth2');
}
} }
/// Helix path for listing channels the user moderates.
bool isTwitchModeratedChannelsPath(String path) =>
path.contains('/moderation/channels');
/// Helix paths for delete / ban / timeout / unban.
bool isTwitchModeratorActionPath(String path) =>
path.contains('/moderation/chat') || path.contains('/moderation/bans');

View file

@ -3,18 +3,6 @@ import 'package:flutter/material.dart';
/// Twitch API client ID. /// Twitch API client ID.
const clientId = String.fromEnvironment('CLIENT_ID'); const clientId = String.fromEnvironment('CLIENT_ID');
/// Twitch GQL client ID (web player, used for playback access tokens).
const twitchGqlClientId = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
/// SharedPreferences key for the per-channel last-selected stream quality.
/// A saved variant that doesn't exist on the target channel (e.g. 1080p on a
/// source-only channel) falls back to Auto via the quality-list lookup.
String lastStreamQualityKey(String userLogin) =>
'last_stream_quality_$userLogin';
/// Legacy global key. Cleared on startup during SharedPreferences init.
const kLegacyLastStreamQualityKey = 'last_stream_quality';
/// Twitch API client secret. /// Twitch API client secret.
const secret = String.fromEnvironment('SECRET'); const secret = String.fromEnvironment('SECRET');

View file

@ -1,30 +1,24 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:advanced_in_app_review/advanced_in_app_review.dart'; import 'package:advanced_in_app_review/advanced_in_app_review.dart';
import 'package:app_links/app_links.dart'; import 'package:app_links/app_links.dart';
import 'package:dio/dio.dart';
import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:firebase_performance/firebase_performance.dart'; import 'package:firebase_performance/firebase_performance.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:frosty/apis/base_api_client.dart' show ApiException;
import 'package:frosty/apis/bttv_api.dart'; import 'package:frosty/apis/bttv_api.dart';
import 'package:frosty/apis/dio_client.dart'; import 'package:frosty/apis/dio_client.dart';
import 'package:frosty/apis/ffz_api.dart'; import 'package:frosty/apis/ffz_api.dart';
import 'package:frosty/apis/seventv_api.dart'; import 'package:frosty/apis/seventv_api.dart';
import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/apis/twitch_auth_interceptor.dart'; import 'package:frosty/apis/twitch_auth_interceptor.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:frosty/apis/unauthorized_interceptor.dart'; import 'package:frosty/apis/unauthorized_interceptor.dart';
import 'package:frosty/cache_manager.dart'; import 'package:frosty/cache_manager.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/firebase_options.dart'; import 'package:frosty/firebase_options.dart';
import 'package:frosty/screens/channel/channel.dart'; import 'package:frosty/screens/channel/channel.dart';
import 'package:frosty/screens/home/home.dart'; import 'package:frosty/screens/home/home.dart';
@ -39,25 +33,6 @@ import 'package:mobx/mobx.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
/// Errors that are expected on a mobile client — transient network/I/O, plugin
/// teardown races, and image fetch failures — and therefore shouldn't be
/// reported to Crashlytics as fatal crashes (which would tank the crash-free
/// metric and bucket unrelated errors together under the global handlers).
///
/// This is a last-resort net. The layers that own these errors already record
/// them non-fatally with precise reasons (e.g. base_api_client, the IRC
/// websocket handlers); this only catches what escapes that local handling.
bool _isNonFatalError(Object error) {
return error is SocketException ||
error is HttpException ||
error is WebSocketChannelException ||
error is ApiException ||
error is MissingPluginException ||
error is NetworkImageLoadException ||
(error is DioException && error.type != DioExceptionType.badResponse);
}
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@ -66,44 +41,17 @@ void main() async {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
// Route uncaught framework errors to Crashlytics. Non-crashing UI errors // Pass all uncaught "fatal" errors from the framework to Crashlytics
// (layout overflows, image-load failures) and other expected transient FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;
// failures are recorded as non-fatal so they don't inflate the crash count.
FlutterError.onError = (details) {
// Run the cheap type checks first; only fall back to the diagnostics string
// (RenderFlex overflow has no typed signal) when those miss.
final isNonFatal =
_isNonFatalError(details.exception) ||
details.exceptionAsString().contains('overflowed');
if (isNonFatal) {
FirebaseCrashlytics.instance.recordFlutterError(details);
} else {
FirebaseCrashlytics.instance.recordFlutterFatalError(details);
}
};
// Pass uncaught asynchronous errors to Crashlytics. Expected transient errors // Pass all uncaught asynchronous errors that aren't handled by the Flutter framework to Crashlytics
// are recorded non-fatally; everything else stays fatal. The reason gives the
// grouping engine a real signal so distinct errors don't collapse into one
// catch-all issue under this handler frame.
PlatformDispatcher.instance.onError = (error, stack) { PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError( FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
error,
stack,
reason: error.runtimeType.toString(),
fatal: !_isNonFatalError(error),
);
return true; return true;
}; };
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
// One-time migration: the quality preference used to be stored under a
// single global key, which meant a saved 1080p60 from one channel could
// pin another channel to a variant it doesn't serve. Remove the legacy
// key; new writes go under per-channel keys.
await prefs.remove(kLegacyLastStreamQualityKey);
final firstRun = prefs.getBool('first_run') ?? true; final firstRun = prefs.getBool('first_run') ?? true;
// Workaround for clearing stored tokens on uninstall. // Workaround for clearing stored tokens on uninstall.
@ -156,7 +104,6 @@ void main() async {
final bttvApiService = BTTVApi(dioClient); final bttvApiService = BTTVApi(dioClient);
final ffzApiService = FFZApi(dioClient); final ffzApiService = FFZApi(dioClient);
final sevenTVApiService = SevenTVApi(dioClient); final sevenTVApiService = SevenTVApi(dioClient);
final twitchGqlApiService = TwitchGqlApi(dioClient);
// Create global assets store (shared cache for global emotes/badges) // Create global assets store (shared cache for global emotes/badges)
final globalAssetsStore = GlobalAssetsStore( final globalAssetsStore = GlobalAssetsStore(
@ -167,10 +114,7 @@ void main() async {
); );
// Create and initialize the authentication store // Create and initialize the authentication store
final authStore = AuthStore( final authStore = AuthStore(twitchApi: twitchApiService);
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));
@ -193,7 +137,6 @@ void main() async {
Provider<BTTVApi>.value(value: bttvApiService), Provider<BTTVApi>.value(value: bttvApiService),
Provider<FFZApi>.value(value: ffzApiService), Provider<FFZApi>.value(value: ffzApiService),
Provider<SevenTVApi>.value(value: sevenTVApiService), Provider<SevenTVApi>.value(value: sevenTVApiService),
Provider<TwitchGqlApi>.value(value: twitchGqlApiService),
Provider<GlobalAssetsStore>.value(value: globalAssetsStore), Provider<GlobalAssetsStore>.value(value: globalAssetsStore),
], ],
child: MyApp(firstRun: firstRun), child: MyApp(firstRun: firstRun),

View file

@ -56,11 +56,6 @@ class IRCMessage {
this.actionLabel, this.actionLabel,
}); });
/// The channel this message actually originated from. In Twitch shared
/// chat, `source-room-id` names the participant channel a message was
/// sent in; otherwise it's just this connection's own room.
String? get sourceChannelId => tags['source-room-id'] ?? tags['room-id'];
/// Applies the given CLEARCHAT message to the provided messages and buffer. /// Applies the given CLEARCHAT message to the provided messages and buffer.
static void clearChat({ static void clearChat({
required List<IRCMessage> messages, required List<IRCMessage> messages,
@ -181,9 +176,7 @@ class IRCMessage {
size: badgeSize, size: badgeSize,
color: color:
Theme.of(context).iconTheme.color?.withValues(alpha: 0.5) ?? Theme.of(context).iconTheme.color?.withValues(alpha: 0.5) ??
Theme.of(context).colorScheme.onSurfaceVariant.withValues( Colors.grey.withValues(alpha: 0.5),
alpha: 0.5,
),
), ),
), ),
), ),
@ -191,6 +184,7 @@ class IRCMessage {
span.add(const TextSpan(text: ' ')); span.add(const TextSpan(text: ' '));
} }
final sourceChannelId = tags['source-room-id'] ?? tags['room-id'];
final sourceChannelUser = channelIdToUserTwitch?[sourceChannelId]; final sourceChannelUser = channelIdToUserTwitch?[sourceChannelId];
if (sourceChannelUser != null) { if (sourceChannelUser != null) {
final isCurrentChannel = sourceChannelId == currentChannelId; final isCurrentChannel = sourceChannelId == currentChannelId;
@ -667,8 +661,6 @@ class IRCMessage {
bool showMessage = true, bool showMessage = true,
Map<String, UserTwitch>? channelIdToUserTwitch, Map<String, UserTwitch>? channelIdToUserTwitch,
TimestampType timestamp = TimestampType.disabled, TimestampType timestamp = TimestampType.disabled,
bool forceTimestamp = false,
bool showHistoricalTimestamps = false,
String? currentChannelId, String? currentChannelId,
}) { }) {
final emoteToObject = assetsStore.emoteToObject; final emoteToObject = assetsStore.emoteToObject;
@ -679,19 +671,7 @@ class IRCMessage {
// The span list that will be used to render the chat message // The span list that will be used to render the chat message
final span = <InlineSpan>[]; final span = <InlineSpan>[];
// When the user has timestamps disabled globally, fall back to the device's _addTimestamp(span, style, timestamp);
// locale preference for historical messages (opt-in via setting) and for
// forced contexts like previews.
final isHistorical = tags['historical'] == '1';
final effectiveTimestamp =
(timestamp == TimestampType.disabled &&
((isHistorical && showHistoricalTimestamps) || forceTimestamp))
? (MediaQuery.alwaysUse24HourFormatOf(context)
? TimestampType.twentyFour
: TimestampType.twelve)
: timestamp;
_addTimestamp(span, style, effectiveTimestamp);
_addHistoricalAndChannelBadges( _addHistoricalAndChannelBadges(
context, context,
span, span,
@ -700,6 +680,7 @@ class IRCMessage {
currentChannelId, currentChannelId,
); );
final isHistorical = tags['historical'] == '1';
_addUserBadges( _addUserBadges(
context, context,
span, span,
@ -978,31 +959,22 @@ class IRCMessage {
// Obtain the index to break off the tags. // Obtain the index to break off the tags.
final tagAndIrcMessageDivider = whole.indexOf(' '); final tagAndIrcMessageDivider = whole.indexOf(' ');
// Without a separating space there are no tags to slice and no message body // Get the tags substring and escape characters.
// to parse — bail out rather than throw on the substring/index calls below. // IRC messages escape spaces with \s.
if (tagAndIrcMessageDivider == -1) { final tags = whole
return IRCMessage(raw: whole, command: Command.none, tags: const {}); .substring(1, tagAndIrcMessageDivider)
} .replaceAll('\\s', ' ');
// Parse the IRCv3 tags into a map. The tags section is everything between // Next, parse and map the tags.
// the leading '@' and the first space. We split on the *raw* string: a
// literal ';' is always a separator because escaped semicolons inside a
// value are encoded as `\:` (unescaped per-value below).
final mappedTags = <String, String>{}; final mappedTags = <String, String>{};
final rawTags = whole.substring(1, tagAndIrcMessageDivider);
for (final tag in rawTags.split(';')) { // Loop through each tag and store their key value pairs into the map.
// Split on the first '=' only — IRCv3 tag values may contain '='. for (final tag in tags.split(';')) {
final equalsIndex = tag.indexOf('='); // Skip if the tag has no value.
if (tag.endsWith('=')) continue;
// Skip valueless tags. Per the IRCv3 spec a missing value (bare `key`) final tagSplit = tag.split('=');
// and an empty value (`key=`) are equivalent — live Twitch sends the mappedTags[tagSplit[0]] = tagSplit[1];
// latter, the recent-messages API the former.
if (equalsIndex == -1 || equalsIndex == tag.length - 1) continue;
mappedTags[tag.substring(0, equalsIndex)] = _unescapeTagValue(
tag.substring(equalsIndex + 1),
);
} }
// Now we'll parse the message itself. // Now we'll parse the message itself.
@ -1016,29 +988,11 @@ class IRCMessage {
// Index 3 and beyond is the :message (word by word) that is sent by the user or empty depending on the command // Index 3 and beyond is the :message (word by word) that is sent by the user or empty depending on the command
final splitMessage = ircMessage.split(' '); final splitMessage = ircMessage.split(' ');
// A well-formed message is at least `<prefix> <COMMAND>`. A malformed or
// truncated line (e.g. from the recent-messages history API) can split into
// a single token, which would otherwise throw a RangeError on the
// splitMessage[0]/[1] accesses below.
if (splitMessage.length < 2) {
return IRCMessage(raw: whole, command: Command.none, tags: mappedTags);
}
// If the username exists, set it. // If the username exists, set it.
// tmi.twitch.tv means the message was sent by Twitch rather than a user, so it is irrelevant. // tmi.twitch.tv means the message was sent by Twitch rather than a user, so will be irrelevant.
final prefix = splitMessage[0]; final String? user = splitMessage[0] == _twitchIrcServer
final String? user; ? mappedTags['login']
if (prefix == _twitchIrcServer) { : splitMessage[0].substring(0, splitMessage[0].indexOf('!'));
user = mappedTags['login'];
} else {
// Normal prefixes are `nick!user@host`. Fall back to the login tag (or
// null) if the expected `!` is absent so an unexpected prefix shape can
// never throw a RangeError and take down the whole message.
final bangIndex = prefix.indexOf('!');
user = bangIndex == -1
? mappedTags['login']
: prefix.substring(0, bangIndex);
}
// If there is an associated message, set it. // If there is an associated message, set it.
String? message; String? message;
@ -1069,9 +1023,9 @@ class IRCMessage {
indexBetweenIdAndPositions, indexBetweenIdAndPositions,
); );
// Parse the range to extract the associated word. // Parse the range in order to extract the associated word.
// If there is more than one index, use the first. // If there are more than one indices, use the first one.
// Otherwise, use the provided index. // Else, use the one provided indices.
final String range; final String range;
if (emoteIdAndPosition.contains(',')) { if (emoteIdAndPosition.contains(',')) {
range = emoteIdAndPosition.substring( range = emoteIdAndPosition.substring(
@ -1199,50 +1153,6 @@ class IRCMessage {
); );
} }
/// Unescapes an IRCv3 message-tag value.
///
/// The spec defines these sequences: `\:` → `;`, `\s` → space, `\\` → `\`,
/// `\r` → CR, `\n` → LF. Any other `\x` collapses to `x`, and a lone trailing
/// backslash is dropped. Done in a single pass so sequences can't be
/// double-unescaped (e.g. `\\s` correctly yields `\s`, not a space).
static String _unescapeTagValue(String value) {
// Fast path: the vast majority of tag values contain no escape sequences.
if (!value.contains('\\')) return value;
final buffer = StringBuffer();
for (var i = 0; i < value.length; i++) {
if (value[i] != '\\') {
buffer.write(value[i]);
continue;
}
// A trailing backslash with nothing after it is dropped.
if (i + 1 == value.length) break;
switch (value[i + 1]) {
case ':':
buffer.write(';');
break;
case 's':
buffer.write(' ');
break;
case '\\':
buffer.write('\\');
break;
case 'r':
buffer.write('\r');
break;
case 'n':
buffer.write('\n');
break;
default:
buffer.write(value[i + 1]);
}
i++; // Skip the escaped character we just consumed.
}
return buffer.toString();
}
factory IRCMessage.createNotice({ factory IRCMessage.createNotice({
required String message, required String message,
VoidCallback? actionCallback, VoidCallback? actionCallback,
@ -1304,9 +1214,8 @@ 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,
// Missing tags must be false — the old `!= '0'` default treated absence as mod. mod: ircMessage.tags['mod'] == '0' ? false : true,
mod: ircMessage.tags['mod'] == '1', subscriber: ircMessage.tags['subscriber'] == '0' ? false : true,
subscriber: ircMessage.tags['subscriber'] == '1',
); );
} }

View file

@ -1,65 +0,0 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
class PlaybackAccessToken {
final String value;
final String signature;
/// Whether Twitch will suppress ads for this session (Turbo, sub, etc.).
/// Defaults to false when the token payload can't be parsed.
final bool hideAds;
/// Quality group names (e.g. "1080p60") that exist for this stream but are
/// restricted to subscribers, so they won't appear in the HLS playlist.
final List<String> restrictedQualities;
const PlaybackAccessToken({
required this.value,
required this.signature,
this.hideAds = false,
this.restrictedQualities = const [],
});
/// Names in restricted_bitrates that aren't real video qualities
/// (matches streamlink's filter).
static final _nonQualityNameRe = RegExp(r'^((.+_)?archives|live|chunked)');
factory PlaybackAccessToken.fromGqlResponse(Map<String, dynamic> json) {
final data =
(json['data'] as Map<String, dynamic>?)?['streamPlaybackAccessToken']
as Map<String, dynamic>?;
if (data == null) {
throw const FormatException(
'streamPlaybackAccessToken missing from GQL response',
);
}
final value = data['value'] as String;
var hideAds = false;
var restricted = const <String>[];
try {
final payload = jsonDecode(value) as Map<String, dynamic>;
hideAds = payload['hide_ads'] as bool? ?? false;
final bitrates =
(payload['chansub'] as Map<String, dynamic>?)?['restricted_bitrates']
as List<dynamic>?;
if (bitrates != null) {
restricted = bitrates
.whereType<String>()
.where((name) => !_nonQualityNameRe.hasMatch(name))
.toList();
}
} catch (e) {
// Token payload format is Twitch-internal — never fail playback over it.
debugPrint('PlaybackAccessToken: failed to parse token payload: $e');
}
return PlaybackAccessToken(
value: value,
signature: data['signature'] as String,
hideAds: hideAds,
restrictedQualities: restricted,
);
}
}

View file

@ -15,14 +15,6 @@ 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,
@ -32,10 +24,8 @@ 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);
@ -48,20 +38,6 @@ 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) =>
final rawPagination = json['pagination']; _$StreamsTwitchFromJson(json);
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,
);
}
} }

View file

@ -16,8 +16,6 @@ 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) =>

View file

@ -1,50 +0,0 @@
/// 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();
}

View file

@ -6,13 +6,9 @@ import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart';
import 'package:frosty/screens/channel/video/channel_info_header.dart';
import 'package:frosty/screens/channel/video/native_video.dart';
import 'package:frosty/screens/channel/video/native_video_store.dart';
import 'package:frosty/screens/channel/video/stream_info_bar.dart'; import 'package:frosty/screens/channel/video/stream_info_bar.dart';
import 'package:frosty/screens/channel/video/video.dart'; import 'package:frosty/screens/channel/video/video.dart';
import 'package:frosty/screens/channel/video/video_overlay.dart'; import 'package:frosty/screens/channel/video/video_overlay.dart';
import 'package:frosty/screens/channel/video/video_player_interface.dart';
import 'package:frosty/screens/channel/video/video_store.dart'; import 'package:frosty/screens/channel/video/video_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/theme.dart'; import 'package:frosty/theme.dart';
@ -47,10 +43,11 @@ class _VideoChatState extends State<VideoChat>
final _videoKey = GlobalKey(); final _videoKey = GlobalKey();
final _chatKey = GlobalKey(); final _chatKey = GlobalKey();
// PiP drag state — ValueNotifier avoids full widget rebuilds during drag. // PiP drag state - essential only
final _pipDragDistance = ValueNotifier<double>(0); double _pipDragDistance = 0;
bool _isPipDragging = false; bool _isPipDragging = false;
bool _isInPipTriggerZone = false; bool _isInPipTriggerZone =
false; // Track when in trigger zone for haptic feedback
// Divider drag state for synchronizing animation // Divider drag state for synchronizing animation
bool _isDividerDragging = false; bool _isDividerDragging = false;
@ -78,24 +75,13 @@ class _VideoChatState extends State<VideoChat>
primaryDisplayName: widget.userName, primaryDisplayName: widget.userName,
); );
late final VideoPlayerInterface _videoStore = late final VideoStore _videoStore = VideoStore(
context.settingsStore.useNativePlayer userLogin: widget.userLogin,
? NativeVideoStore( userId: widget.userId,
userLogin: widget.userLogin, twitchApi: context.twitchApi,
userId: widget.userId, authStore: context.authStore,
displayName: widget.userName, settingsStore: context.settingsStore,
twitchApi: context.twitchApi, );
twitchGqlApi: context.twitchGqlApi,
authStore: context.authStore,
settingsStore: context.settingsStore,
)
: VideoStore(
userLogin: widget.userLogin,
userId: widget.userId,
twitchApi: context.twitchApi,
authStore: context.authStore,
settingsStore: context.settingsStore,
);
@override @override
void initState() { void initState() {
@ -112,10 +98,12 @@ class _VideoChatState extends State<VideoChat>
CurvedAnimation(parent: _animationController, curve: Curves.easeOut), CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
); );
// Drive the ValueNotifier from the spring-back animation so the // Attach listener to the controller (not the derived animation) so it
// AnimatedBuilder rebuilds without a full setState. // survives _springBackAnimation being reassigned in _animateSpringBack.
_animationController.addListener(() { _animationController.addListener(() {
_pipDragDistance.value = _springBackAnimation.value; setState(() {
_pipDragDistance = _springBackAnimation.value;
});
}); });
// Register as observer for app lifecycle events // Register as observer for app lifecycle events
@ -132,58 +120,58 @@ class _VideoChatState extends State<VideoChat>
} }
void _handlePipDragStart(DragStartDetails details) { void _handlePipDragStart(DragStartDetails details) {
if (_videoStore.isInPipMode || _videoStore.paused || _videoStore.loading) { // Disable drag gesture when already in PiP mode or video is not playing
return; if (_videoStore.isInPipMode || _videoStore.paused) return;
}
_animationController.stop(); _animationController.stop(); // Stop any ongoing animation
_pipDragDistance.value = 0;
_isInPipTriggerZone = false;
setState(() { setState(() {
_isPipDragging = true; _isPipDragging = true;
_pipDragDistance = 0;
_isInPipTriggerZone = false;
}); });
} }
void _handlePipDragUpdate(DragUpdateDetails details) { void _handlePipDragUpdate(DragUpdateDetails details) {
if (!_isPipDragging || if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) {
_videoStore.isInPipMode ||
_videoStore.paused ||
_videoStore.loading) {
return; return;
} }
// Update the ValueNotifier directly — only the ListenableBuilder rebuilds, setState(() {
// not the entire channel scaffold. _pipDragDistance += details.delta.dy;
_pipDragDistance.value = _pipDragDistance = _pipDragDistance.clamp(0, _pipMaxDragDistance);
(_pipDragDistance.value + details.delta.dy).clamp(0, _pipMaxDragDistance);
final wasInTriggerZone = _isInPipTriggerZone; // Check if we've entered or exited the trigger zone for haptic feedback
_isInPipTriggerZone = _pipDragDistance.value >= _pipTriggerDistance; final wasInTriggerZone = _isInPipTriggerZone;
_isInPipTriggerZone = _pipDragDistance >= _pipTriggerDistance;
if (!wasInTriggerZone && _isInPipTriggerZone) { // Provide haptic feedback when entering the trigger zone
HapticFeedback.mediumImpact(); if (!wasInTriggerZone && _isInPipTriggerZone) {
} else if (wasInTriggerZone && !_isInPipTriggerZone) { HapticFeedback.mediumImpact(); // Entering trigger zone
HapticFeedback.lightImpact(); }
} // Provide subtle haptic feedback when exiting the trigger zone
else if (wasInTriggerZone && !_isInPipTriggerZone) {
HapticFeedback.lightImpact(); // Exiting trigger zone
}
});
} }
void _handlePipDragEnd(DragEndDetails details) { void _handlePipDragEnd(DragEndDetails details) {
if (!_isPipDragging || if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) {
_videoStore.isInPipMode ||
_videoStore.paused ||
_videoStore.loading) {
return; return;
} }
final velocity = details.velocity.pixelsPerSecond.dy; final velocity = details.velocity.pixelsPerSecond.dy;
final shouldTriggerPip = final shouldTriggerPip =
_pipDragDistance.value >= _pipTriggerDistance || velocity > 600; _pipDragDistance >= _pipTriggerDistance ||
velocity > 600; // Simple velocity threshold
if (shouldTriggerPip) { if (shouldTriggerPip) {
// Simple haptic feedback on success
HapticFeedback.mediumImpact(); HapticFeedback.mediumImpact();
_videoStore.requestPictureInPicture(); _videoStore.requestPictureInPicture();
_resetDragState(); _resetDragState();
} else { } else {
// Animate back to original position
_animateSpringBack(); _animateSpringBack();
} }
} }
@ -194,16 +182,16 @@ class _VideoChatState extends State<VideoChat>
} }
void _resetDragState() { void _resetDragState() {
_pipDragDistance.value = 0;
_isInPipTriggerZone = false;
setState(() { setState(() {
_isPipDragging = false; _isPipDragging = false;
_pipDragDistance = 0;
_isInPipTriggerZone = false;
}); });
} }
void _animateSpringBack() { void _animateSpringBack() {
_springBackAnimation = _springBackAnimation = Tween<double>(begin: _pipDragDistance, end: 0)
Tween<double>(begin: _pipDragDistance.value, end: 0).animate( .animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeOut), CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
); );
@ -241,13 +229,13 @@ class _VideoChatState extends State<VideoChat>
? AspectRatio(aspectRatio: aspectRatio, child: child) ? AspectRatio(aspectRatio: aspectRatio, child: child)
: child; : child;
// Listen to the ValueNotifier (drag) and AnimationController (spring-back) return AnimatedBuilder(
// so only this subtree rebuilds during the gesture — not the full scaffold. animation: _animationController,
return ListenableBuilder(
listenable: _pipDragDistance,
child: wrappedChild, child: wrappedChild,
builder: (context, animatedChild) { builder: (context, animatedChild) {
final currentDragDistance = _pipDragDistance.value; final currentDragDistance = _isPipDragging
? _pipDragDistance
: _springBackAnimation.value;
final scaleFactor = final scaleFactor =
1.0 - (currentDragDistance / _pipMaxDragDistance * 0.1); 1.0 - (currentDragDistance / _pipMaxDragDistance * 0.1);
@ -268,13 +256,11 @@ class _VideoChatState extends State<VideoChat>
if (!_videoStore.isInPipMode) if (!_videoStore.isInPipMode)
Positioned.fill( Positioned.fill(
child: IgnorePointer( child: IgnorePointer(
ignoring: ignoring: !(_isPipDragging && _pipDragDistance > 0),
!(_isPipDragging && currentDragDistance > 0),
child: AnimatedOpacity( child: AnimatedOpacity(
opacity: opacity: (_isPipDragging && _pipDragDistance > 0)
(_isPipDragging && currentDragDistance > 0) ? 1.0
? 1.0 : 0.0,
: 0.0,
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
curve: Curves.easeOut, curve: Curves.easeOut,
child: Container( child: Container(
@ -311,19 +297,7 @@ class _VideoChatState extends State<VideoChat>
final player = GestureDetector( final player = GestureDetector(
onLongPress: _videoStore.handleToggleOverlay, onLongPress: _videoStore.handleToggleOverlay,
child: _videoStore is NativeVideoStore child: Video(key: _videoKey, videoStore: _videoStore),
? NativeVideo(
// GlobalKey so the native platform view is *reparented* (not
// rebuilt) across the portrait↔landscape layout swap — without
// it, rotating destroys and recreates the player, forcing a
// reload. Mirrors the WebView Video below.
key: _videoKey,
nativeVideoStore: _videoStore,
)
: Video(
key: _videoKey,
videoStore: _videoStore as VideoStore,
),
); );
final overlay = GestureDetector( final overlay = GestureDetector(
@ -350,9 +324,7 @@ class _VideoChatState extends State<VideoChat>
settingsStore: settingsStore, settingsStore: settingsStore,
); );
if (_videoStore.paused || if (_videoStore.paused || _videoStore.streamInfo == null) {
_videoStore.streamInfo == null ||
_videoStore.loading) {
return videoOverlay; return videoOverlay;
} }
@ -372,49 +344,15 @@ class _VideoChatState extends State<VideoChat>
), ),
); );
final video = Stack( final video = Observer(
children: [ builder: (context) {
player, return Stack(
Positioned.fill( children: [
child: Observer( player,
builder: (_) => AnimatedOpacity( if (_videoStore.settingsStore.showOverlay) overlay,
opacity: _videoStore.loading ? 1.0 : 0.0, ],
// Appear instantly to cover native player placeholders during );
// recovery; fade out smoothly when playback resumes. },
duration: _videoStore.loading
? Duration.zero
: const Duration(milliseconds: 300),
curve: Curves.easeOut,
// TickerMode stops the spinner's repeating animation while
// hidden — opacity 0 skips painting but would otherwise keep
// scheduling frames for the whole video session.
child: IgnorePointer(
child: TickerMode(
enabled: _videoStore.loading,
child: const ColoredBox(
color: Colors.black,
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: Colors.white38,
strokeWidth: 2,
),
),
),
),
),
),
),
),
),
Observer(
builder: (_) => _videoStore.settingsStore.showOverlay
? overlay
: const SizedBox.shrink(),
),
],
); );
final chat = Observer( final chat = Observer(
@ -745,15 +683,7 @@ class _VideoChatState extends State<VideoChat>
if (settingsStore.showVideo) ...[ if (settingsStore.showVideo) ...[
AspectRatio( AspectRatio(
aspectRatio: 16 / 9, aspectRatio: 16 / 9,
child: SizedBox(), child: Container(), // Placeholder for video space
),
ChannelInfoHeader(
videoStore: _videoStore,
authStore: context.authStore,
userId: widget.userId,
displayName: widget.userName,
isInSharedChatMode:
_chatStore.isInSharedChatMode,
), ),
], ],
Expanded(child: chat), Expanded(child: chat),
@ -780,16 +710,9 @@ class _VideoChatState extends State<VideoChat>
// If on Android, use PiPSwitcher to enable PiP functionality. // If on Android, use PiPSwitcher to enable PiP functionality.
if (Platform.isAndroid) { if (Platform.isAndroid) {
// Both players need this — the WebView store used to be skipped by a
// NativeVideoPlayerInterface type check, so it never learned it was in
// PiP on Android.
void notifyPip(bool isInPip) => _videoStore.handleAndroidPipChanged(isInPip);
return PipWidget( return PipWidget(
pipLayout: PipActionsLayout.mediaOnlyPause, pipLayout: PipActionsLayout.mediaOnlyPause,
onPipAction: (_) => _videoStore.handlePausePlay(), onPipAction: (_) => _videoStore.handlePausePlay(),
onPipEntered: () => notifyPip(true),
onPipExited: () => notifyPip(false),
pipChild: player, pipChild: player,
child: videoChat, child: videoChat,
); );
@ -808,7 +731,6 @@ class _VideoChatState extends State<VideoChat>
_videoStore.dispose(); _videoStore.dispose();
_animationController.dispose(); _animationController.dispose();
_pipDragDistance.dispose();
SystemChrome.setEnabledSystemUIMode( SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual, SystemUiMode.manual,

View file

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart'; import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart';
import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart'; import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart';
@ -12,10 +11,6 @@ import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/frosty_page_view.dart'; import 'package:frosty/widgets/frosty_page_view.dart';
import 'package:frosty/widgets/frosty_scrollbar.dart'; import 'package:frosty/widgets/frosty_scrollbar.dart';
/// ~2x default to keep richer message widgets (emotes, badges, replies) built
/// ahead of fast scroll-back without holding the entire history.
const _chatCacheExtent = ScrollCacheExtent.pixels(500.0);
class Chat extends StatelessWidget { class Chat extends StatelessWidget {
final ChatStore chatStore; final ChatStore chatStore;
final EdgeInsetsGeometry? listPadding; final EdgeInsetsGeometry? listPadding;
@ -86,59 +81,25 @@ class Chat extends StatelessWidget {
? chatTabsStore!.mergedScrollController ? chatTabsStore!.mergedScrollController
: chatStore.scrollController; : chatStore.scrollController;
// Freeze the message list while a finger rests on a return FrostyScrollbar(
// message so a long-press target can't scroll out controller: scrollController,
// from under it (which would dispose its recognizer padding: EdgeInsets.only(
// mid-press). The store resumes flowing as soon as top: MediaQuery.of(context).padding.top,
// the pointer moves like a scroll, so scrolling is bottom:
// never interrupted — only a stationary hold pauses. chatStore.bottomBarHeight + bottomPadding,
void onDown(PointerDownEvent e) => _isMerged ),
? chatTabsStore!.onMessageListPointerDown( child: Observer(
e.pointer, builder: (context) {
e.position, return _isMerged
) ? _buildMergedList(
: chatStore.onMessageListPointerDown( scrollController,
e.pointer, bottomPadding,
e.position, )
); : _buildNormalList(
void onMove(PointerMoveEvent e) => _isMerged scrollController,
? chatTabsStore!.onMessageListPointerMove( bottomPadding,
e.pointer, );
e.position, },
)
: chatStore.onMessageListPointerMove(
e.pointer,
e.position,
);
void onUp(int pointer) => _isMerged
? chatTabsStore!.onMessageListPointerUp(pointer)
: chatStore.onMessageListPointerUp(pointer);
return Listener(
onPointerDown: onDown,
onPointerMove: onMove,
onPointerUp: (e) => onUp(e.pointer),
onPointerCancel: (e) => onUp(e.pointer),
child: FrostyScrollbar(
controller: scrollController,
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top,
bottom:
chatStore.bottomBarHeight + bottomPadding,
),
child: Observer(
builder: (context) {
return _isMerged
? _buildMergedList(
scrollController,
bottomPadding,
)
: _buildNormalList(
scrollController,
bottomPadding,
);
},
),
), ),
); );
}, },
@ -179,25 +140,18 @@ class Chat extends StatelessWidget {
return ListView.builder( return ListView.builder(
reverse: true, reverse: true,
padding: (listPadding ?? EdgeInsets.zero).add( padding: (listPadding ?? EdgeInsets.zero).add(
EdgeInsets.only(bottom: chatStore.bottomBarHeight + bottomPadding), EdgeInsets.only(
bottom: chatStore.bottomBarHeight + bottomPadding,
),
), ),
addAutomaticKeepAlives: false, addAutomaticKeepAlives: false,
scrollCacheExtent: _chatCacheExtent,
controller: scrollController, controller: scrollController,
itemCount: chatStore.renderMessages.length, itemCount: chatStore.renderMessages.length,
itemBuilder: (context, index) { itemBuilder: (context, index) => ChatMessage(
final ircMessage = chatStore ircMessage: chatStore.renderMessages[
.renderMessages[chatStore.renderMessages.length - 1 - index]; chatStore.renderMessages.length - 1 - index],
// Key by message identity so the list reconciles by message rather chatStore: chatStore,
// than by slot. This keeps each message's element (and its gesture ),
// recognizers) bound to the same message when new messages arrive
// mid-interaction, fixing long-press landing on the wrong message.
return ChatMessage(
key: ObjectKey(ircMessage),
ircMessage: ircMessage,
chatStore: chatStore,
);
},
); );
} }
@ -206,22 +160,24 @@ class Chat extends StatelessWidget {
double bottomPadding, double bottomPadding,
) { ) {
final mergedMessages = chatTabsStore!.mergedMessages; final mergedMessages = chatTabsStore!.mergedMessages;
final channelIdToUserTwitch = chatTabsStore!.mergedChannelIdToUserTwitch; final channelIdToUserTwitch =
chatTabsStore!.mergedChannelIdToUserTwitch;
final currentChannelId = chatTabsStore!.activeTab.channelId; final currentChannelId = chatTabsStore!.activeTab.channelId;
return ListView.builder( return ListView.builder(
reverse: true, reverse: true,
padding: (listPadding ?? EdgeInsets.zero).add( padding: (listPadding ?? EdgeInsets.zero).add(
EdgeInsets.only(bottom: chatStore.bottomBarHeight + bottomPadding), EdgeInsets.only(
bottom: chatStore.bottomBarHeight + bottomPadding,
),
), ),
addAutomaticKeepAlives: false, addAutomaticKeepAlives: false,
scrollCacheExtent: _chatCacheExtent,
controller: scrollController, controller: scrollController,
itemCount: mergedMessages.length, itemCount: mergedMessages.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final merged = mergedMessages[mergedMessages.length - 1 - index]; final merged =
mergedMessages[mergedMessages.length - 1 - index];
return ChatMessage( return ChatMessage(
key: ObjectKey(merged.ircMessage),
ircMessage: merged.ircMessage, ircMessage: merged.ircMessage,
chatStore: merged.chatStore, chatStore: merged.chatStore,
inputChatStore: chatStore, inputChatStore: chatStore,
@ -255,7 +211,8 @@ class Chat extends StatelessWidget {
right: 4, right: 4,
bottom: bottom:
chatStore.bottomBarHeight + chatStore.bottomBarHeight +
(chatStore.assetsStore.showEmoteMenu || isHorizontalLandscape (chatStore.assetsStore.showEmoteMenu ||
isHorizontalLandscape
? 0 ? 0
: MediaQuery.of(context).padding.bottom), : MediaQuery.of(context).padding.bottom),
), ),
@ -279,13 +236,17 @@ class Chat extends StatelessWidget {
? null ? null
: ElevatedButton.icon( : ElevatedButton.icon(
onPressed: onResume, onPressed: onResume,
icon: const Icon(Icons.arrow_downward_rounded), icon: const Icon(
Icons.arrow_downward_rounded,
),
label: Text( label: Text(
bufferCount > 0 bufferCount > 0
? '$bufferCount new ${bufferCount == 1 ? 'message' : 'messages'}' ? '$bufferCount new ${bufferCount == 1 ? 'message' : 'messages'}'
: 'Resume scroll', : 'Resume scroll',
style: const TextStyle( style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()], fontFeatures: [
FontFeature.tabularFigures(),
],
), ),
), ),
), ),
@ -298,78 +259,76 @@ class Chat extends StatelessWidget {
} }
Widget _buildEmoteMenu(BuildContext context) { Widget _buildEmoteMenu(BuildContext context) {
final menuHeight = context.screenHeight / (context.isPortrait ? 3 : 2);
return AnimatedContainer( return AnimatedContainer(
curve: Curves.ease, curve: Curves.ease,
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
height: chatStore.assetsStore.showEmoteMenu ? menuHeight : 0, height: chatStore.assetsStore.showEmoteMenu
? context.screenHeight / (context.isPortrait ? 3 : 2)
: 0,
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: const Duration(milliseconds: 100), duration: const Duration(milliseconds: 100),
switchInCurve: Curves.easeOut, switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn, switchOutCurve: Curves.easeIn,
child: chatStore.assetsStore.showEmoteMenu child: chatStore.assetsStore.showEmoteMenu
? ClipRect( ? ClipRect(
// Lay the content out at its full target height regardless of child: Column(
// the animated container's current height, then clip — the children: [
// Column would otherwise overflow (RenderFlex error) as the const Divider(),
// height tweens toward zero and can no longer fit the Divider. Expanded(
child: OverflowBox( child: FrostyPageView(
minHeight: 0, headers: [
maxHeight: menuHeight, 'Recent',
alignment: Alignment.topCenter, if (chatStore.settings.showTwitchEmotes)
child: Column( 'Twitch',
children: [ if (chatStore.settings.show7TVEmotes) '7TV',
const Divider(), if (chatStore.settings.showBTTVEmotes) 'BTTV',
Expanded( if (chatStore.settings.showFFZEmotes) 'FFZ',
child: FrostyPageView( ],
headers: [ tabActions: {
'Recent', if (chatStore
if (chatStore.settings.showTwitchEmotes) 'Twitch', .assetsStore
if (chatStore.settings.show7TVEmotes) '7TV', .recentEmotes
if (chatStore.settings.showBTTVEmotes) 'BTTV', .isNotEmpty)
if (chatStore.settings.showFFZEmotes) 'FFZ', 0: IconButton(
], onPressed: () {
tabActions: { chatStore.assetsStore.recentEmotes.clear();
if (chatStore.assetsStore.recentEmotes.isNotEmpty) chatStore.updateNotification(
0: IconButton( 'Recent emotes cleared',
onPressed: () { );
chatStore.assetsStore.recentEmotes.clear(); },
chatStore.updateNotification( icon: const Icon(
'Recent emotes cleared', Icons.delete_outline_rounded,
);
},
icon: const Icon(Icons.delete_outline_rounded),
tooltip: 'Clear recent emotes',
iconSize: 20,
),
},
children: [
RecentEmotesPanel(chatStore: chatStore),
if (chatStore.settings.showTwitchEmotes)
EmoteMenuPanel(
chatStore: chatStore,
twitchEmotes: chatStore
.assetsStore
.userEmoteSectionToEmotes,
),
...[
if (chatStore.settings.show7TVEmotes)
chatStore.assetsStore.sevenTVEmotes,
if (chatStore.settings.showBTTVEmotes)
chatStore.assetsStore.bttvEmotes,
if (chatStore.settings.showFFZEmotes)
chatStore.assetsStore.ffzEmotes,
].map(
(emotes) => EmoteMenuPanel(
chatStore: chatStore,
emotes: emotes,
), ),
tooltip: 'Clear recent emotes',
iconSize: 20,
), ),
], },
), children: [
RecentEmotesPanel(chatStore: chatStore),
if (chatStore.settings.showTwitchEmotes)
EmoteMenuPanel(
chatStore: chatStore,
twitchEmotes: chatStore
.assetsStore
.userEmoteSectionToEmotes,
),
...[
if (chatStore.settings.show7TVEmotes)
chatStore.assetsStore.sevenTVEmotes,
if (chatStore.settings.showBTTVEmotes)
chatStore.assetsStore.bttvEmotes,
if (chatStore.settings.showFFZEmotes)
chatStore.assetsStore.ffzEmotes,
].map(
(emotes) => EmoteMenuPanel(
chatStore: chatStore,
emotes: emotes,
),
),
],
), ),
], ),
), ],
), ),
) )
: null, : null,

View file

@ -171,7 +171,7 @@ class ChatModes extends StatelessWidget {
style: TextStyle( style: TextStyle(
color: Theme.of( color: Theme.of(
context, context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.7), ).colorScheme.onSurfaceVariant,
), ),
), ),
], ],

View file

@ -1,31 +0,0 @@
/// Tracks which users are currently banned or timed out, learned from IRC
/// CLEARCHAT events, so the chat can offer an "unban / remove timeout" action
/// only when it's actually applicable.
///
/// Keyed by Twitch user id. A value of null means a permanent ban; a non-null
/// [DateTime] is when a timeout expires. Pure logic (clock passed in) so it can
/// be unit-tested without the chat store.
class BannedUserTracker {
final _expiries = <String, DateTime?>{};
/// Record a permanent ban (CLEARCHAT without a ban-duration).
void recordBan(String userId) => _expiries[userId] = null;
/// Record a timeout (CLEARCHAT with a ban-duration), expiring at [now] + [duration].
void recordTimeout(String userId, Duration duration, DateTime now) =>
_expiries[userId] = now.add(duration);
/// Forget a user (e.g. after a successful unban).
void clear(String userId) => _expiries.remove(userId);
/// Whether [userId] is currently banned or within an unexpired timeout.
/// Expired timeouts are pruned on read.
bool isBannedOrTimedOut(String userId, DateTime now) {
if (!_expiries.containsKey(userId)) return false;
final expiry = _expiries[userId];
if (expiry == null) return true; // permanent ban
if (now.isBefore(expiry)) return true; // timeout still active
_expiries.remove(userId); // expired — clean up
return false;
}
}

View file

@ -130,7 +130,7 @@ abstract class ChatAssetsStoreBase with Store {
@action @action
Future<void> init() async { Future<void> init() async {
// Load SharedPreferences for recent emotes. // Retrieve the instance that will allow us to retrieve local search history.
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_recentEmotes = _recentEmotes =
@ -290,18 +290,6 @@ abstract class ChatAssetsStoreBase with Store {
await Future.wait(futures); await Future.wait(futures);
} }
/// Whether any shared chat participant assets have been merged in.
bool get hasLoadedSharedChatAssets => _loadedSharedChannelIds.isNotEmpty;
/// Clears tracking of merged shared chat participant assets so that leaving
/// and later re-entering a shared chat session re-fetches cleanly. The caller
/// must re-fetch this channel's own assets afterwards (which replaces the
/// merged emote/badge maps). Must run within a MobX action.
void resetSharedChatAssets() {
_loadedSharedChannelIds.clear();
sharedSevenTvSetIds.clear();
}
/// Load and cache shared chat participants for a broadcaster. /// Load and cache shared chat participants for a broadcaster.
/// Returns the list of new participant broadcaster IDs (deduped). /// Returns the list of new participant broadcaster IDs (deduped).
@action @action

View file

@ -1,54 +0,0 @@
import 'package:flutter/gestures.dart';
/// Decides whether the chat message list should hold buffered messages back
/// because the user is *holding* a message (a long-press candidate).
///
/// Flushing buffered messages grows the reverse list and pushes the pressed
/// message up and out of the build range, which disposes its gesture recognizer
/// mid-press and cancels the long-press. Freezing the list while a finger rests
/// on a message keeps it under the finger so the press lands.
///
/// Crucially, the freeze applies *only to a stationary finger*. The moment a
/// pointer travels past [kTouchSlop] it's a scroll, not a hold, so the chat
/// resumes flowing — otherwise freezing then batch-releasing mid-scroll makes
/// scrolling feel like it gets cancelled. This is the same threshold the
/// long-press recognizer uses to tell a hold from a drag.
///
/// Tracks pointers individually so multi-touch resolves correctly: the chat
/// only resumes once every stationary pointer has lifted or started moving.
class ChatInteractionPause {
/// Down position of each active pointer, by pointer id.
final _origins = <int, Offset>{};
/// Pointers that have moved past the slop this gesture — i.e. are scrolling.
/// Once a pointer is here it stays until it lifts, so a drag never flips back
/// to a hold mid-gesture.
final _scrolling = <int>{};
/// Whether any pointer is down and still stationary (a long-press candidate).
bool get isPaused => _origins.length > _scrolling.length;
/// Register a pointer touching down at [position].
void pointerDown(int pointer, Offset position) {
_origins[pointer] = position;
}
/// Update a pointer's position; flags it as scrolling once it passes the slop.
void pointerMove(int pointer, Offset position) {
final origin = _origins[pointer];
if (origin == null) return;
if ((position - origin).distance > kTouchSlop) _scrolling.add(pointer);
}
/// Register a pointer lifting off (or being cancelled).
void pointerUp(int pointer) {
_origins.remove(pointer);
_scrolling.remove(pointer);
}
/// Force-clear all active pointers (e.g. on dispose or tab switch).
void reset() {
_origins.clear();
_scrolling.clear();
}
}

View file

@ -10,15 +10,11 @@ import 'package:frosty/models/emotes.dart';
import 'package:frosty/models/events.dart'; import 'package:frosty/models/events.dart';
import 'package:frosty/models/irc.dart'; import 'package:frosty/models/irc.dart';
import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
import 'package:frosty/screens/channel/chat/stores/banned_user_tracker.dart';
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_interaction_pause.dart';
import 'package:frosty/screens/channel/chat/stores/shared_chat_bubble_state.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/utils.dart'; import 'package:frosty/utils.dart';
import 'package:mobx/mobx.dart'; import 'package:mobx/mobx.dart';
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/web_socket_channel.dart';
part 'chat_store.g.dart'; part 'chat_store.g.dart';
@ -30,7 +26,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 amount of messages to render when autoscroll is enabled. /// The maximum ammount 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.
@ -70,41 +66,32 @@ abstract class ChatStoreBase with Store {
'msg_bad_characters', 'msg_bad_characters',
}; };
/// Fallback height used to pad the message list for one frame, until the /// Base height of the bottom bar (input field area).
/// real bottom bar reports its measured height via [setBottomBarHeight].
static const _baseBottomBarHeight = 68.0; static const _baseBottomBarHeight = 68.0;
/// Server-initiated graceful reconnect notice. Twitch sends this untagged /// Height of the autocomplete bar (SizedBox + Divider).
/// before maintenance so clients can re-establish ahead of the close. static const _autocompleteHeight = 51.0;
static const _serverReconnectNotice = ':tmi.twitch.tv RECONNECT';
/// Muted words pre-normalized (trimmed, lowercased, empties dropped) by a /// Height of the reply bar (Container + Divider).
/// reaction on the setting — avoids re-normalizing on every message. static const _replyBarHeight = 41.0;
var _normalizedMutedWords = <String>[];
/// Whether a single IRC [line] should bypass the chat delay and be handled /// Checks if IRC data contains a command that should bypass the chat delay.
/// immediately. Displayable chat content (PRIVMSG/USERNOTICE and the /// Returns true if any message in the data contains a bypass command.
/// moderation CLEARCHAT/CLEARMSG) is delayed, so it stays in sync with the bool _shouldBypassDelay(String data) {
/// delayed video. Everything else is handled at once: control/state messages for (final message in data.trimRight().split('\r\n')) {
/// (USERSTATE/ROOMSTATE/GLOBALUSERSTATE), NOTICE (so a send rejection surfaces if (message.startsWith('@')) {
/// immediately instead of one chat-delay later), the reconnect notice, and final parts = message.split(' ');
/// any untagged server line (PING keepalives, CAP, JOIN, the welcome banner) if (parts.length >= 3 && _delayBypassCommands.contains(parts[2])) {
/// — delaying those would stall the send-ack, drop the PONG, or never start return true;
/// the buffer drain. }
bool _lineShouldBypassDelay(String line) { }
if (line.isEmpty || line == _serverReconnectNotice) return true; }
// Displayable chat content is always IRCv3-tagged; an untagged line is return false;
// never delayed.
if (!line.startsWith('@')) return true;
final parts = line.split(' ');
return parts.length >= 3 && _delayBypassCommands.contains(parts[2]);
} }
Duration get _chatDelay => int get _chatDelaySeconds => settings.effectiveChatDelay.toInt();
Duration(milliseconds: (settings.effectiveChatDelay * 1000).round());
bool get _hasActiveChatDelay => bool get _hasActiveChatDelay => settings.showVideo && _chatDelaySeconds > 0;
settings.showVideo && _chatDelay > Duration.zero;
final TwitchApi twitchApi; final TwitchApi twitchApi;
@ -144,12 +131,6 @@ abstract class ChatStoreBase with Store {
static const _maxRetries = 5; static const _maxRetries = 5;
/// Ping interval for both the IRC and 7TV WebSocket sockets. Catches
/// zombie connections (silent network changes, OS suspends) without
/// waking the radio every 15s. Twitch's own recommended keepalive tops
/// out at 600s; community libs (e.g. dank-twitch-irc) sit around 30s.
static const _pingInterval = Duration(seconds: 60);
// The retry counter for exponential backoff. // The retry counter for exponential backoff.
var _retries = 0; var _retries = 0;
@ -177,12 +158,6 @@ abstract class ChatStoreBase with Store {
/// Requested message to be sent by the user. Will only be sent on receipt of a USERNOTICE command. /// Requested message to be sent by the user. Will only be sent on receipt of a USERNOTICE command.
IRCMessage? toSend; IRCMessage? toSend;
/// Raw text of the most recent outgoing message, retained until the send is
/// confirmed. Lets the USERSTATE handler build the local echo even when the
/// message was sent before this session's first USERSTATE arrived (so
/// [toSend] couldn't be built up front).
String? _pendingSentMessageText;
/// The list of reaction disposer functions that will be used later when disposing. /// The list of reaction disposer functions that will be used later when disposing.
final reactions = <ReactionDisposer>[]; final reactions = <ReactionDisposer>[];
@ -202,40 +177,11 @@ abstract class ChatStoreBase with Store {
/// The set of message IDs that have been revealed by the user (for deleted messages). /// The set of message IDs that have been revealed by the user (for deleted messages).
final revealedMessageIds = ObservableSet<String>(); final revealedMessageIds = ObservableSet<String>();
/// User-driven spotlight/hide selections for shared chat participant
/// channels. See [SharedChatBubbleState].
final sharedChatBubbles = SharedChatBubbleState();
@action @action
void revealMessage(String id) { void revealMessage(String id) {
revealedMessageIds.add(id); revealedMessageIds.add(id);
} }
/// Whether a message from [sourceChannelId] should render faded.
///
/// [overrideCurrentChannelId] is set only when rendering the manual
/// "merged mode" view (multiple manually-added tabs interleaved), which
/// always fades non-active-tab messages, gated by [focusCurrentChannel].
/// Otherwise this is the normal single-connection view, where native
/// Twitch shared chat defaults to unfaded and only fades once the user
/// spotlights a participant channel via a chat bubble — an explicit
/// action that isn't gated by that setting.
bool shouldFadeMessage({
required String? sourceChannelId,
required String? overrideCurrentChannelId,
}) {
if (sourceChannelId == null) return false;
final currentChannelId = overrideCurrentChannelId ?? channelId;
if (sourceChannelId == currentChannelId) return false;
final isMerged = overrideCurrentChannelId != null;
if (isMerged) return settings.focusCurrentChannel;
return sharedChatBubbles.focusedChannelIds.isNotEmpty &&
!sharedChatBubbles.isFocused(sourceChannelId);
}
/// Timer used for dismissing the notification. /// Timer used for dismissing the notification.
Timer? _notificationTimer; Timer? _notificationTimer;
@ -271,51 +217,21 @@ 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-range index error with sublist. // The second condition is to prevent an out of index error with sublist.
final base = (!_autoScroll || _messages.length < _renderMessageLimit) if (!_autoScroll || _messages.length < _renderMessageLimit) {
? _messages return _messages;
: _messages.sublist(_messages.length - _renderMessageLimit); }
// Filter out messages from shared chat participant channels the user // When autoscroll is enabled, only show the first [_renderMessageLimit] messages.
// has hidden. Short-circuit when nothing is hidden (the common case). // This will improve performance by only rendering a limited amount of messages
if (sharedChatBubbles.hiddenChannelIds.isEmpty) return base; // instead of the entire history at all times.
return base.where((message) { return _messages.sublist(_messages.length - _renderMessageLimit);
final sourceChannelId = message.sourceChannelId;
return sourceChannelId == null ||
!sharedChatBubbles.hiddenChannelIds.contains(sourceChannelId);
}).toList();
} }
/// If the chat should automatically scroll/jump to the latest message. /// If the chat should automatically scroll/jump to the latest message.
@readonly @readonly
var _autoScroll = true; var _autoScroll = true;
/// Tracks users banned/timed-out via CLEARCHAT so the moderation menu only
/// offers an unban action when it applies.
final _bannedUserTracker = BannedUserTracker();
/// Whether [userId] is currently banned or within an unexpired timeout
/// (as observed from CLEARCHAT in this session).
bool isUserBannedOrTimedOut(String userId) =>
_bannedUserTracker.isBannedOrTimedOut(userId, DateTime.now());
/// Forget a user's ban state after a successful unban so the action hides.
void clearUserBanState(String userId) => _bannedUserTracker.clear(userId);
/// Freezes the rendered list while the user is touching it, so a long-press
/// target can't scroll out from under the finger and get its gesture
/// recognizer disposed mid-press. Plain field (not observable): it only gates
/// the periodic [addMessages] flush, which reads it each 200ms tick.
final _interactionPause = ChatInteractionPause();
/// Hooks for the message list to report touch start/move/end (see [Chat]).
void onMessageListPointerDown(int pointer, Offset position) =>
_interactionPause.pointerDown(pointer, position);
void onMessageListPointerMove(int pointer, Offset position) =>
_interactionPause.pointerMove(pointer, position);
void onMessageListPointerUp(int pointer) =>
_interactionPause.pointerUp(pointer);
@readonly @readonly
var _inputText = ''; var _inputText = '';
@ -384,26 +300,25 @@ abstract class ChatStoreBase with Store {
.toList(); .toList();
} }
/// Measured height of the rendered bottom bar (input plus any reply or /// Current bottom bar height based on visible overlays.
/// autocomplete overlays). The bar reports this via [setBottomBarHeight] so /// Used to dynamically adjust chat message list padding.
/// the message list's bottom padding tracks the bar's *true* height —
/// including multi-line input growth and OS text scaling, which fixed
/// per-overlay constants silently undercount.
final _measuredBottomBarHeight = Observable<double?>(null);
/// Current bottom bar height, used to dynamically pad the message list so the
/// bar never overlaps messages. Falls back to the single-line base height
/// until the real bar reports its first measurement.
@computed @computed
double get bottomBarHeight => double get bottomBarHeight {
_measuredBottomBarHeight.value ?? _baseBottomBarHeight; var height = _baseBottomBarHeight;
/// Reports the bar's real rendered height. Sub-pixel changes are ignored to // Add reply bar height when replying to a message.
/// avoid needless message-list relayouts. if (replyingToMessage != null) {
void setBottomBarHeight(double height) { height += _replyBarHeight;
final current = _measuredBottomBarHeight.value; }
if (current != null && (current - height).abs() < 0.5) return;
runInAction(() => _measuredBottomBarHeight.value = height); // Add autocomplete bar height when showing matches.
if (settings.autocomplete &&
((_showEmoteAutocomplete && matchingEmotes.isNotEmpty) ||
(_showMentionAutocomplete && matchingChatters.isNotEmpty))) {
height += _autocompleteHeight;
}
return height;
} }
ChatStoreBase({ ChatStoreBase({
@ -422,17 +337,6 @@ abstract class ChatStoreBase with Store {
reaction((_) => auth.isLoggedIn, (_) => _channel?.sink.close(1000)), reaction((_) => auth.isLoggedIn, (_) => _channel?.sink.close(1000)),
); );
// Normalize muted words once per settings change instead of per message
// — the filter runs in the IRC hot path (50+ msgs/s in busy channels).
reactions.add(
reaction((_) => settings.mutedWords.toList(), (List<String> words) {
_normalizedMutedWords = words
.map((word) => word.trim().toLowerCase())
.where((word) => word.isNotEmpty)
.toList();
}, fireImmediately: true),
);
reactions.add( reactions.add(
reaction( reaction(
(_) => [ (_) => [
@ -501,10 +405,6 @@ abstract class ChatStoreBase with Store {
connectToChat(); connectToChat();
} }
// Refresh which channels the user moderates on entry so mod actions reflect
// status granted/revoked since login. Fire-and-forget, like blocked users.
if (auth.isLoggedIn) auth.user.refreshModeratedChannels();
// Tell the scrollController to determine when auto-scroll should be enabled or disabled. // Tell the scrollController to determine when auto-scroll should be enabled or disabled.
scrollController.addListener(() { scrollController.addListener(() {
// If the scroll position is at the latest message (maximum possible), enable autoscroll. // If the scroll position is at the latest message (maximum possible), enable autoscroll.
@ -549,7 +449,7 @@ abstract class ChatStoreBase with Store {
) { ) {
queue.add( queue.add(
_DelayedCallback( _DelayedCallback(
releaseAt: DateTime.now().add(_chatDelay), releaseAt: DateTime.now().add(Duration(seconds: _chatDelaySeconds)),
callback: callback, callback: callback,
), ),
); );
@ -618,29 +518,11 @@ abstract class ChatStoreBase with Store {
// To account for this, split by CRLF, then loop and process each message. // To account for this, split by CRLF, then loop and process each message.
for (final message in data.trimRight().split('\r\n')) { for (final message in data.trimRight().split('\r\n')) {
// debugPrint('$message\n'); // debugPrint('$message\n');
if (message == _serverReconnectNotice) {
// Twitch is about to close the socket for maintenance — close it
// ourselves so the existing onDone path reconnects with no backoff
// (first retry is immediate) instead of waiting for the server to
// drop us mid-message.
_channel?.sink.close(1000);
continue;
}
if (message.startsWith('@')) { if (message.startsWith('@')) {
// Parse defensively: a single malformed message must never abort the final parsedIRCMessage = IRCMessage.fromString(
// loop, or one bad frame would drop every other message batched with message,
// it (e.g. an entire recent-messages backlog). Skip and keep going. userLogin: auth.user.details?.login,
final IRCMessage parsedIRCMessage; );
try {
parsedIRCMessage = IRCMessage.fromString(
message,
userLogin: auth.user.details?.login,
);
} catch (e) {
debugPrint('Failed to parse IRC message: $e');
continue;
}
if (parsedIRCMessage.user != null) { if (parsedIRCMessage.user != null) {
chatDetailsStore.chatUsers.add(parsedIRCMessage.user!); chatDetailsStore.chatUsers.add(parsedIRCMessage.user!);
@ -658,17 +540,19 @@ abstract class ChatStoreBase with Store {
continue; continue;
} }
// Filter messages containing any muted words. Must use `continue` // Filter messages containing any muted words.
// (not `return`) — a single IRC frame can batch many messages, and if (parsedIRCMessage.message != null) {
// returning would drop the rest of the frame with the matched one. final List<String> mutedWords = settings.mutedWords;
if (parsedIRCMessage.message != null &&
_normalizedMutedWords.isNotEmpty) { // check if the message contains any of the muted words
final msg = parsedIRCMessage.message!.toLowerCase(); for (final word in mutedWords) {
final words = settings.matchWholeWord ? msg.split(' ') : null; if (parsedIRCMessage.message!
final muted = _normalizedMutedWords.any( .toLowerCase()
(word) => words != null ? words.contains(word) : msg.contains(word), .split(settings.matchWholeWord ? ' ' : '')
); .contains(word.toLowerCase())) {
if (muted) continue; return;
}
}
} }
switch (parsedIRCMessage.command) { switch (parsedIRCMessage.command) {
@ -684,9 +568,6 @@ abstract class ChatStoreBase with Store {
_sendingTimeoutTimer?.cancel(); _sendingTimeoutTimer?.cancel();
_isWaitingForAck = false; _isWaitingForAck = false;
toSend = null; toSend = null;
// Drop the retained text so a later USERSTATE can't render the
// rejected message as a deferred echo.
_pendingSentMessageText = null;
// Show notification with Twitch's rejection message // Show notification with Twitch's rejection message
if (parsedIRCMessage.message != null) { if (parsedIRCMessage.message != null) {
@ -696,82 +577,40 @@ abstract class ChatStoreBase with Store {
} }
} }
// Update shared chat mode based on source-room-id tag presence. // Update shared chat mode based on source-room-id tag presence
// Only PRIVMSG reliably carries this tag for the session's final wasShared = _isInSharedChatMode;
// duration — NOTICE/USERNOTICE (subs, raids, announcements) _isInSharedChatMode = parsedIRCMessage.tags.containsKey(
// don't always, so gating on those too would flap this flag off 'source-room-id',
// and on within a single live session (now visibly, since the );
// shared chat bubble bar reads it — previously this only risked // On transition into shared chat mode, fetch assets for participants.
// a redundant asset refetch, guarded below). if (!wasShared && _isInSharedChatMode) {
if (parsedIRCMessage.command == Command.privateMessage) { assetsStore.fetchSharedChatAssets(
final wasShared = _isInSharedChatMode; channelId: channelId,
_isInSharedChatMode = parsedIRCMessage.tags.containsKey( headers: auth.headersTwitch,
'source-room-id', onEmoteError: (error) {
debugPrint(error.toString());
return <Emote>[];
},
onBadgeError: (error) {
debugPrint(error.toString());
return <ChatBadge>[];
},
showTwitchEmotes: settings.showTwitchEmotes,
showTwitchBadges: settings.showTwitchBadges,
show7TVEmotes: settings.show7TVEmotes,
showBTTVEmotes: settings.showBTTVEmotes,
showFFZEmotes: settings.showFFZEmotes,
showFFZBadges: settings.showFFZBadges,
); );
// On transition into shared chat mode, fetch assets for participants.
if (!wasShared && _isInSharedChatMode) {
assetsStore.fetchSharedChatAssets(
channelId: channelId,
headers: auth.headersTwitch,
onEmoteError: (error) {
debugPrint(error.toString());
return <Emote>[];
},
onBadgeError: (error) {
debugPrint(error.toString());
return <String, ChatBadge>{};
},
showTwitchEmotes: settings.showTwitchEmotes,
showTwitchBadges: settings.showTwitchBadges,
show7TVEmotes: settings.show7TVEmotes,
showBTTVEmotes: settings.showBTTVEmotes,
showFFZEmotes: settings.showFFZEmotes,
showFFZBadges: settings.showFFZBadges,
);
} else if (wasShared &&
!_isInSharedChatMode &&
assetsStore.hasLoadedSharedChatAssets) {
// On leaving shared chat, drop the participants' merged emotes and
// badges and restore just this channel's assets — otherwise the
// other streamer's emote set and badges linger (#522). Guarded on
// having actually loaded shared assets so a flapping tag can't
// trigger redundant refetches.
assetsStore.resetSharedChatAssets();
getAssets();
}
} }
messageBuffer.add(parsedIRCMessage); messageBuffer.add(parsedIRCMessage);
break; break;
case Command.clearChat: case Command.clearChat:
// Track the banned/timed-out user so the chat can offer an unban
// action only when applicable. target-user-id is absent for a
// whole-chat clear.
final clearedUserId = parsedIRCMessage.tags['target-user-id'];
if (clearedUserId != null) {
final banDuration = parsedIRCMessage.tags['ban-duration'];
final seconds = banDuration != null
? int.tryParse(banDuration)
: null;
if (seconds != null) {
_bannedUserTracker.recordTimeout(
clearedUserId,
Duration(seconds: seconds),
DateTime.now(),
);
} else {
_bannedUserTracker.recordBan(clearedUserId);
}
}
IRCMessage.clearChat( IRCMessage.clearChat(
messages: _messages, messages: _messages,
bufferedMessages: messageBuffer, bufferedMessages: messageBuffer,
ircMessage: parsedIRCMessage, ircMessage: parsedIRCMessage,
); );
// clearChat marks messages in place, which doesn't notify the
// observable list; reassign so renderMessages recomputes and the
// ban/timeout strike-through shows without waiting for the next
// message.
_messages = _messages.toList().asObservable();
break; break;
case Command.clearMessage: case Command.clearMessage:
IRCMessage.clearMessage( IRCMessage.clearMessage(
@ -779,9 +618,6 @@ abstract class ChatStoreBase with Store {
bufferedMessages: messageBuffer, bufferedMessages: messageBuffer,
ircMessage: parsedIRCMessage, ircMessage: parsedIRCMessage,
); );
// Same as clearChat: force a re-render so the deleted message
// updates immediately rather than on the next incoming message.
_messages = _messages.toList().asObservable();
break; break;
case Command.roomState: case Command.roomState:
chatDetailsStore.roomState = chatDetailsStore.roomState chatDetailsStore.roomState = chatDetailsStore.roomState
@ -790,13 +626,23 @@ abstract class ChatStoreBase with Store {
case Command.userState: case Command.userState:
_userState = _userState.fromIRCMessage(parsedIRCMessage); _userState = _userState.fromIRCMessage(parsedIRCMessage);
// A USERSTATE following our PRIVMSG confirms the send. Finalize // USERSTATE arrival confirms message was sent, even without id tag
// regardless of whether a local echo was pre-built — gating this on if (toSend != null) {
// `toSend` left the player stuck "sending" (and firing a false final messageId = parsedIRCMessage.tags['id'];
// "may not have been sent") whenever the message went out before if (messageId != null) {
// this session's first USERSTATE populated `_userState`. toSend!.tags['id'] = messageId;
if (_isWaitingForAck) { } else {
_finalizeSend(parsedIRCMessage.tags['id']); // Fallback ID for edge cases where Twitch doesn't provide one
toSend!.tags['id'] =
'local-${DateTime.now().millisecondsSinceEpoch}';
}
messageBuffer.add(toSend!);
toSend = null;
// Reset sending state and clear text/reply after successful confirmation
_sendingTimeoutTimer?.cancel();
_isWaitingForAck = false;
textController.clear();
replyingToMessage = null;
} }
break; break;
case Command.globalUserState: case Command.globalUserState:
@ -894,12 +740,9 @@ abstract class ChatStoreBase with Store {
return <Emote>[]; return <Emote>[];
} }
// Returns a Map, not a List: this is attached via `.catchError` to the List<ChatBadge> onBadgeError(dynamic error) {
// `getBadgesChannel` future (a Future<Map<String, ChatBadge>>), so the
// handler must return that type or Dart throws at runtime.
Map<String, ChatBadge> onBadgeError(dynamic error) {
debugPrint(error.toString()); debugPrint(error.toString());
return <String, ChatBadge>{}; return <ChatBadge>[];
} }
final baseAssets = assetsStore.assetsFuture( final baseAssets = assetsStore.assetsFuture(
@ -963,9 +806,8 @@ abstract class ChatStoreBase with Store {
_sevenTVChannel?.sink.close(1000); _sevenTVChannel?.sink.close(1000);
_clearPendingDelayedCallbacks(clearChat: false); _clearPendingDelayedCallbacks(clearChat: false);
_sevenTVChannel = IOWebSocketChannel.connect( _sevenTVChannel = WebSocketChannel.connect(
Uri.parse('wss://events.7tv.io/v3'), Uri.parse('wss://events.7tv.io/v3'),
pingInterval: _pingInterval,
); );
void listener(dynamic data) { void listener(dynamic data) {
@ -1052,9 +894,8 @@ abstract class ChatStoreBase with Store {
_clearPendingDelayedCallbacks(clearSevenTV: false); _clearPendingDelayedCallbacks(clearSevenTV: false);
_channel?.sink.close(1000); _channel?.sink.close(1000);
_channel = IOWebSocketChannel.connect( _channel = WebSocketChannel.connect(
Uri.parse('wss://irc-ws.chat.twitch.tv:443'), Uri.parse('wss://irc-ws.chat.twitch.tv:443'),
pingInterval: _pingInterval,
); );
// Only show chat delay countdown on initial connection or video toggle, not on reconnects // Only show chat delay countdown on initial connection or video toggle, not on reconnects
@ -1067,27 +908,13 @@ abstract class ChatStoreBase with Store {
(data) { (data) {
final dataStr = data.toString(); final dataStr = data.toString();
// No active delay: process the whole frame immediately. // Process immediately if delay is disabled or command should bypass delay
if (!_hasActiveChatDelay) { if (!_hasActiveChatDelay || _shouldBypassDelay(dataStr)) {
_handleIRCData(dataStr); _handleIRCData(dataStr);
return; } else {
} _enqueueDelayedCallback(_pendingChatCallbacks, () {
_handleIRCData(dataStr);
// Dispatch each line individually so a bypass-eligible control message });
// isn't dragged behind the delay by chat lines batched in the same
// frame — and, conversely, so a delayed chat line can't hold up a
// PING/USERSTATE that shares the frame.
for (final line in dataStr.split('\r\n')) {
final trimmed = line.trimRight();
if (trimmed.isEmpty) continue;
if (_lineShouldBypassDelay(trimmed)) {
_handleIRCData(trimmed);
} else {
_enqueueDelayedCallback(
_pendingChatCallbacks,
() => _handleIRCData(trimmed),
);
}
} }
}, },
onError: (error) { onError: (error) {
@ -1108,14 +935,6 @@ abstract class ChatStoreBase with Store {
_cancelChatDelayCountdown(); _cancelChatDelayCountdown();
_chatDelaySyncCompleted = false; _chatDelaySyncCompleted = false;
// Reset send state — any in-flight message is unrecoverable across
// a disconnect, so don't leave the user blocked waiting for an ack
// that will never arrive.
_sendingTimeoutTimer?.cancel();
_isWaitingForAck = false;
toSend = null;
_pendingSentMessageText = null;
if (_shouldDisconnect) { if (_shouldDisconnect) {
_sevenTVChannel?.sink.close(1000); _sevenTVChannel?.sink.close(1000);
return; return;
@ -1255,11 +1074,7 @@ abstract class ChatStoreBase with Store {
void addMessages() { void addMessages() {
_flushDueDelayedCallbacks(); _flushDueDelayedCallbacks();
// Hold messages back while the user is touching the list so the pressed if (!_autoScroll || messageBuffer.isEmpty) return;
// message stays put (and alive) for the duration of a long-press.
if (!_autoScroll || _interactionPause.isPaused || messageBuffer.isEmpty) {
return;
}
_messages.addAll(messageBuffer); _messages.addAll(messageBuffer);
messageBuffer.clear(); messageBuffer.clear();
@ -1290,7 +1105,7 @@ abstract class ChatStoreBase with Store {
messageBuffer.clear(); messageBuffer.clear();
} }
var remainingSeconds = _chatDelay.inSeconds; var remainingSeconds = _chatDelaySeconds;
// Create and store reference to the countdown message // Create and store reference to the countdown message
_countdownMessage = IRCMessage.createNotice( _countdownMessage = IRCMessage.createNotice(
@ -1375,93 +1190,53 @@ abstract class ChatStoreBase with Store {
// Text field and reply state cleared on USERSTATE confirmation (not optimistically) // Text field and reply state cleared on USERSTATE confirmation (not optimistically)
// to preserve message text if Twitch rejects it (e.g., slow mode) // to preserve message text if Twitch rejects it (e.g., slow mode)
// Fallback in case the confirming USERSTATE never arrives. Twitch can omit // Start timeout timer in case server doesn't respond (network failure)
// or drop the USERSTATE that acknowledges a send — intermittently, and more // Must be outside userStateString conditional to always have a safety net
// often under a long chat delay — even though the message was delivered.
// A missing ack is therefore NOT evidence of failure, so rather than cry
// wolf with a "may not have been sent" error (which pushes the user to
// resend and double-post), optimistically finalize the send: render the
// local echo and clear the pending state. Genuine rejections almost always
// arrive first — the rejection NOTICE bypasses the chat delay, so it lands
// well within this window and clears the pending send before the timeout. A
// rejection arriving after the timeout has finalized is treated as sent (the
// echo stays), an accepted edge given how early NOTICEs arrive. Scaled by
// the chat delay so a legitimately delayed USERSTATE is given time to land.
_sendingTimeoutTimer?.cancel(); _sendingTimeoutTimer?.cancel();
_sendingTimeoutTimer = Timer(const Duration(seconds: 10) + _chatDelay, () { _sendingTimeoutTimer = Timer(const Duration(seconds: 10), () {
if (_shouldDisconnect || !_isWaitingForAck) return; // Guard against execution after disposal
runInAction(() => _finalizeSend(null)); if (_shouldDisconnect) return;
if (_isWaitingForAck) {
_isWaitingForAck = false;
toSend = null;
HapticFeedback.heavyImpact();
updateNotification('Message may not have been sent. Please try again.');
}
}); });
// Build the optimistic local echo from the current USERSTATE. If the // Obtain the logged-in user's appearance in chat with USERSTATE and create the full message to render.
// message is sent before this session's first USERSTATE arrives,
// _buildLocalEcho returns null and the echo is built later, when the
// confirming USERSTATE lands (see the Command.userState handler). The raw
// text is retained so that deferred build is possible.
_pendingSentMessageText = message;
toSend = _buildLocalEcho(message);
}
/// Renders the pending outgoing message's local echo (building it lazily if
/// it couldn't be built at send time) and clears all send-pending state.
/// Shared by the USERSTATE confirmation and the optimistic timeout fallback.
/// [serverMessageId] is the `id` tag from the confirming USERSTATE, or null
/// when finalizing optimistically without one.
void _finalizeSend(String? serverMessageId) {
toSend ??= _pendingSentMessageText != null
? _buildLocalEcho(_pendingSentMessageText!)
: null;
if (toSend != null) {
// Prefer the server id; fall back to any id already set, then a local one.
toSend!.tags['id'] =
serverMessageId ??
toSend!.tags['id'] ??
'local-${DateTime.now().millisecondsSinceEpoch}';
messageBuffer.add(toSend!);
toSend = null;
}
_pendingSentMessageText = null;
_sendingTimeoutTimer?.cancel();
_isWaitingForAck = false;
textController.clear();
replyingToMessage = null;
}
/// Builds the optimistic local echo of an outgoing [message] from the current
/// USERSTATE, or null if no USERSTATE has been received yet this session
/// (the echo can't be rendered without the user's badges/color/display-name).
IRCMessage? _buildLocalEcho(String message) {
var userStateString = _userState.raw; var userStateString = _userState.raw;
if (userStateString == null) return null; if (userStateString != null) {
if (message.length > 3 && message.substring(0, 3) == '/me') {
userStateString +=
' :\x01ACTION ${message.replaceRange(0, 3, '').trim()}\x01';
} else {
userStateString +=
' :${replyingToMessage?.tags['display-name'] != null ? '@${replyingToMessage!.tags['display-name']} ' : ''}${message.trim()}';
}
if (message == '/me' || message.startsWith('/me ')) { final userChatMessage = IRCMessage.fromString(userStateString);
userStateString += userChatMessage.localEmotes?.addAll(assetsStore.userEmoteToObject);
' :\x01ACTION ${message.replaceRange(0, 3, '').trim()}\x01'; if (auth.isLoggedIn && auth.user.details != null) {
} else { userChatMessage.tags['user-id'] = auth.user.details!.id;
userStateString += }
' :${replyingToMessage?.tags['display-name'] != null ? '@${replyingToMessage!.tags['display-name']} ' : ''}${message.trim()}';
if (replyingToMessage != null && replyingToMessage!.tags['id'] != null) {
userChatMessage.tags['reply-parent-msg-id'] =
replyingToMessage!.tags['id']!;
userChatMessage.tags['reply-parent-display-name'] =
replyingToMessage!.tags['display-name'] ?? '';
userChatMessage.tags['reply-parent-msg-body'] =
replyingToMessage!.message ?? '';
}
// Ensure a timestamp so merged-mode sorting works correctly.
userChatMessage.tags['tmi-sent-ts'] ??=
'${DateTime.now().millisecondsSinceEpoch}';
toSend = userChatMessage;
} }
final userChatMessage = IRCMessage.fromString(userStateString);
userChatMessage.localEmotes?.addAll(assetsStore.userEmoteToObject);
if (auth.isLoggedIn && auth.user.details != null) {
userChatMessage.tags['user-id'] = auth.user.details!.id;
}
if (replyingToMessage != null && replyingToMessage!.tags['id'] != null) {
userChatMessage.tags['reply-parent-msg-id'] =
replyingToMessage!.tags['id']!;
userChatMessage.tags['reply-parent-display-name'] =
replyingToMessage!.tags['display-name'] ?? '';
userChatMessage.tags['reply-parent-msg-body'] =
replyingToMessage!.message ?? '';
}
// Ensure a timestamp so merged-mode sorting works correctly.
userChatMessage.tags['tmi-sent-ts'] ??=
'${DateTime.now().millisecondsSinceEpoch}';
return userChatMessage;
} }
/// Adds the given [emote] to the chat textfield. /// Adds the given [emote] to the chat textfield.
@ -1568,8 +1343,6 @@ abstract class ChatStoreBase with Store {
void dispose() { void dispose() {
_shouldDisconnect = true; _shouldDisconnect = true;
_interactionPause.reset();
_clearPendingDelayedCallbacks(); _clearPendingDelayedCallbacks();
_messageBufferTimer?.cancel(); _messageBufferTimer?.cancel();

View file

@ -9,7 +9,6 @@ import 'package:frosty/models/irc.dart';
import 'package:frosty/models/user.dart'; import 'package:frosty/models/user.dart';
import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_interaction_pause.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart';
@ -56,10 +55,6 @@ class ChatTabInfo {
/// The ChatStore for this tab. Null until the tab is activated (lazy loading). /// The ChatStore for this tab. Null until the tab is activated (lazy loading).
ChatStore? chatStore; ChatStore? chatStore;
/// The id of the most recent message visible the last time the user viewed
/// this tab. Used to compute unread state — null means "no baseline yet".
String? lastSeenMessageId;
/// Whether this is the primary tab (first tab, cannot be removed). /// Whether this is the primary tab (first tab, cannot be removed).
final bool isPrimary; final bool isPrimary;
@ -152,19 +147,6 @@ abstract class ChatTabsStoreBase with Store {
/// Timer that drives merged message rendering at a fixed cadence. /// Timer that drives merged message rendering at a fixed cadence.
Timer? _mergedRenderTimer; Timer? _mergedRenderTimer;
/// Freezes the rendered merged list while the user is touching it, so a
/// long-press target can't scroll out from under the finger. Gates the
/// periodic [_refreshMergedMessages]; see [ChatInteractionPause].
final _interactionPause = ChatInteractionPause();
/// Hooks for the merged message list to report touch start/move/end (see [Chat]).
void onMessageListPointerDown(int pointer, Offset position) =>
_interactionPause.pointerDown(pointer, position);
void onMessageListPointerMove(int pointer, Offset position) =>
_interactionPause.pointerMove(pointer, position);
void onMessageListPointerUp(int pointer) =>
_interactionPause.pointerUp(pointer);
/// Frozen snapshot of merged messages when user scrolls up. /// Frozen snapshot of merged messages when user scrolls up.
/// Prevents new messages from causing scroll jumps while reading. /// Prevents new messages from causing scroll jumps while reading.
List<MergedMessage>? _mergedSnapshot; List<MergedMessage>? _mergedSnapshot;
@ -202,9 +184,8 @@ abstract class ChatTabsStoreBase with Store {
int? tailCount, int? tailCount,
}) { }) {
final msgs = store.messages; final msgs = store.messages;
final start = tailCount != null && msgs.length > tailCount final start =
? msgs.length - tailCount tailCount != null && msgs.length > tailCount ? msgs.length - tailCount : 0;
: 0;
for (var i = start; i < msgs.length; i++) { for (var i = start; i < msgs.length; i++) {
final id = msgs[i].tags['id']; final id = msgs[i].tags['id'];
if (id != null && !seenIds.add(id)) continue; if (id != null && !seenIds.add(id)) continue;
@ -240,12 +221,7 @@ abstract class ChatTabsStoreBase with Store {
final seenIds = <String>{}; final seenIds = <String>{};
for (final tab in _tabs) { for (final tab in _tabs) {
if (tab.chatStore == null) continue; if (tab.chatStore == null) continue;
_collectMessages( _collectMessages(tab.chatStore!, recent, seenIds, tailCount: _mergedRenderLimit);
tab.chatStore!,
recent,
seenIds,
tailCount: _mergedRenderLimit,
);
} }
recent.sort(_compareByTimestamp); recent.sort(_compareByTimestamp);
if (recent.length > _mergedRenderLimit) { if (recent.length > _mergedRenderLimit) {
@ -269,9 +245,7 @@ abstract class ChatTabsStoreBase with Store {
/// Refreshes the rendered merged messages. Called by the render timer. /// Refreshes the rendered merged messages. Called by the render timer.
@action @action
void _refreshMergedMessages() { void _refreshMergedMessages() {
// Hold the rendered list still while the user is touching it so a if (!_mergedAutoScroll) return;
// long-press target stays put for the duration of the press.
if (!_mergedAutoScroll || _interactionPause.isPaused) return;
_mergedRenderedMessages = _computeRecentMergedMessages(); _mergedRenderedMessages = _computeRecentMergedMessages();
} }
@ -282,8 +256,7 @@ abstract class ChatTabsStoreBase with Store {
for (final tab in _tabs) { for (final tab in _tabs) {
if (tab.chatStore != null) { if (tab.chatStore != null) {
total += total +=
tab.chatStore!.messages.length + tab.chatStore!.messages.length + tab.chatStore!.messageBuffer.length;
tab.chatStore!.messageBuffer.length;
} }
} }
return total; return total;
@ -542,9 +515,10 @@ abstract class ChatTabsStoreBase with Store {
), ),
); );
// Switch to the new tab, snapshotting the outgoing tab's lastSeenMessageId. // Switch to the new tab (this will activate it)
final newIndex = _tabs.length - 1; final newIndex = _tabs.length - 1;
setActiveTab(newIndex); activateTab(newIndex);
activeTabIndex = newIndex;
// If in merged mode, fetch the new tab's channel profile // If in merged mode, fetch the new tab's channel profile
if (mergedMode) { if (mergedMode) {
@ -646,8 +620,10 @@ abstract class ChatTabsStoreBase with Store {
// Primary tab (index 0) cannot be moved, and nothing can move before it // Primary tab (index 0) cannot be moved, and nothing can move before it
if (oldIndex == 0 || newIndex == 0) return; if (oldIndex == 0 || newIndex == 0) return;
// onReorderItem already adjusts newIndex for the removed item, so no // ReorderableListView passes newIndex as if item was already removed
// manual off-by-one correction is needed here. if (oldIndex < newIndex) {
newIndex -= 1;
}
if (oldIndex == newIndex) return; if (oldIndex == newIndex) return;
final tab = _tabs.removeAt(oldIndex); final tab = _tabs.removeAt(oldIndex);
@ -665,30 +641,6 @@ abstract class ChatTabsStoreBase with Store {
_syncSecondaryTabsToSettings(); _syncSecondaryTabsToSettings();
} }
/// Returns the last message id in [messages] that has a non-null tag id,
/// or null if no such message exists. Skips system messages without ids.
String? _latestMessageId(List<IRCMessage> messages) {
for (var i = messages.length - 1; i >= 0; i--) {
final id = messages[i].tags['id'];
if (id != null) return id;
}
return null;
}
/// True if [index] has unread messages since the user last viewed it.
/// Always false for the active tab and in merged mode.
bool hasUnreadMessages(int index) {
if (mergedMode) return false;
if (index == activeTabIndex) return false;
if (index < 0 || index >= _tabs.length) return false;
final tab = _tabs[index];
final store = tab.chatStore;
if (store == null) return false;
final latest = _latestMessageId(store.messages);
if (latest == null) return false;
return latest != tab.lastSeenMessageId;
}
/// Sets the active tab to the given index. /// Sets the active tab to the given index.
/// ///
/// When [silent] is true, the current tab's draft text and reply state are /// When [silent] is true, the current tab's draft text and reply state are
@ -697,18 +649,6 @@ abstract class ChatTabsStoreBase with Store {
@action @action
void setActiveTab(int index, {bool silent = false}) { void setActiveTab(int index, {bool silent = false}) {
if (index >= 0 && index < _tabs.length) { if (index >= 0 && index < _tabs.length) {
// Snapshot the outgoing tab's most recent message id so the unread
// dot for that tab clears, and only future messages count as unread.
if (index != activeTabIndex &&
activeTabIndex >= 0 &&
activeTabIndex < _tabs.length) {
final outgoing = _tabs[activeTabIndex];
final outStore = outgoing.chatStore;
if (outStore != null) {
outgoing.lastSeenMessageId = _latestMessageId(outStore.messages);
}
}
// Clear text input and emote menu when switching tabs. // Clear text input and emote menu when switching tabs.
// Skip in merged mode — tabs act as send-target selectors, not // Skip in merged mode — tabs act as send-target selectors, not
// view switches, so draft/reply state should be preserved. // view switches, so draft/reply state should be preserved.
@ -741,7 +681,6 @@ abstract class ChatTabsStoreBase with Store {
void dispose() { void dispose() {
_mergedRenderTimer?.cancel(); _mergedRenderTimer?.cancel();
_mergedRenderTimer = null; _mergedRenderTimer = null;
_interactionPause.reset();
for (final tab in _tabs) { for (final tab in _tabs) {
tab.chatStore?.dispose(); tab.chatStore?.dispose();
} }

View file

@ -1,45 +0,0 @@
import 'package:mobx/mobx.dart';
/// Tracks user-driven spotlight/hide selections for participant channels in
/// a Twitch shared chat session (multiple broadcasters merged onto one IRC
/// connection). Pure state so it can be unit-tested without the chat store.
///
/// [ObservableSet] is reactive on its own, so [Observer] widgets reading
/// [focusedChannelIds]/[hiddenChannelIds] rebuild without any MobX codegen.
class SharedChatBubbleState {
/// Channel ids the user has spotlighted (kept unfaded). Empty means no
/// spotlight is active and nothing is faded.
final focusedChannelIds = ObservableSet<String>();
/// Channel ids whose messages are filtered out of the feed entirely.
final hiddenChannelIds = ObservableSet<String>();
/// Adds/removes [channelId] from the spotlight set. No-ops for a hidden
/// channel — its messages are filtered out, so spotlighting it would fade
/// every other channel to showcase nothing.
void toggleFocus(String channelId) {
if (hiddenChannelIds.contains(channelId)) return;
if (!focusedChannelIds.remove(channelId)) {
focusedChannelIds.add(channelId);
}
}
/// Hides [channelId]'s messages and drops it from the spotlight set.
void hide(String channelId) {
hiddenChannelIds.add(channelId);
focusedChannelIds.remove(channelId);
}
/// Un-hides [channelId]'s messages.
void show(String channelId) => hiddenChannelIds.remove(channelId);
bool isFocused(String channelId) => focusedChannelIds.contains(channelId);
bool isHidden(String channelId) => hiddenChannelIds.contains(channelId);
/// Clears both sets (e.g. when a shared chat session ends).
void reset() {
focusedChannelIds.clear();
hiddenChannelIds.clear();
}
}

View file

@ -11,7 +11,6 @@ import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:frosty/widgets/blurred_container.dart'; import 'package:frosty/widgets/blurred_container.dart';
import 'package:frosty/widgets/chat_input/emote_text_span_builder.dart'; import 'package:frosty/widgets/chat_input/emote_text_span_builder.dart';
import 'package:frosty/widgets/frosty_cached_network_image.dart'; import 'package:frosty/widgets/frosty_cached_network_image.dart';
import 'package:frosty/widgets/measure_size.dart';
class ChatBottomBar extends StatelessWidget { class ChatBottomBar extends StatelessWidget {
final ChatStore chatStore; final ChatStore chatStore;
@ -394,13 +393,6 @@ class ChatBottomBar extends StatelessWidget {
final needsBottomPadding = final needsBottomPadding =
!chatStore.assetsStore.showEmoteMenu && !isHorizontalLandscape; !chatStore.assetsStore.showEmoteMenu && !isHorizontalLandscape;
// Measured once here; the list pads itself by this height so the bar
// never overlaps messages. Shared by both container branches below.
final measuredBar = MeasureSize(
onChange: (size) => chatStore.setBottomBarHeight(size.height),
child: bottomBarContent,
);
return isFullscreenOverlay return isFullscreenOverlay
? Padding( ? Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
@ -408,7 +400,7 @@ class ChatBottomBar extends StatelessWidget {
? MediaQuery.of(context).padding.bottom ? MediaQuery.of(context).padding.bottom
: 0, : 0,
), ),
child: measuredBar, child: bottomBarContent,
) )
: BlurredContainer( : BlurredContainer(
gradientDirection: GradientDirection.down, gradientDirection: GradientDirection.down,
@ -417,7 +409,7 @@ class ChatBottomBar extends StatelessWidget {
? MediaQuery.of(context).padding.bottom ? MediaQuery.of(context).padding.bottom
: 0, : 0,
), ),
child: measuredBar, child: bottomBarContent,
); );
}, },
); );

View file

@ -8,7 +8,6 @@ import 'package:frosty/models/irc.dart';
import 'package:frosty/models/user.dart'; import 'package:frosty/models/user.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart';
import 'package:frosty/screens/channel/chat/widgets/moderation_actions.dart';
import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart'; import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart';
import 'package:frosty/utils/context_extensions.dart'; import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart'; import 'package:frosty/utils/modal_bottom_sheet.dart';
@ -18,7 +17,6 @@ class ChatMessage extends StatelessWidget {
final IRCMessage ircMessage; final IRCMessage ircMessage;
final ChatStore chatStore; final ChatStore chatStore;
final bool isModal; final bool isModal;
final bool forceTimestamp;
final bool showReplyHeader; final bool showReplyHeader;
final bool isInReplyThread; final bool isInReplyThread;
@ -41,7 +39,6 @@ class ChatMessage extends StatelessWidget {
required this.ircMessage, required this.ircMessage,
required this.chatStore, required this.chatStore,
this.isModal = false, this.isModal = false,
this.forceTimestamp = false,
this.showReplyHeader = true, this.showReplyHeader = true,
this.isInReplyThread = false, this.isInReplyThread = false,
this.overrideChannelIdToUserTwitch, this.overrideChannelIdToUserTwitch,
@ -68,7 +65,6 @@ class ChatMessage extends StatelessWidget {
username: ircMessage.user!, username: ircMessage.user!,
userId: ircMessage.tags['user-id']!, userId: ircMessage.tags['user-id']!,
displayName: ircMessage.tags['display-name']!, displayName: ircMessage.tags['display-name']!,
onActivateSourceTab: onActivateSourceTab,
), ),
); );
} }
@ -88,7 +84,6 @@ class ChatMessage extends StatelessWidget {
username: user.login, username: user.login,
userId: user.id, userId: user.id,
displayName: user.displayName, displayName: user.displayName,
onActivateSourceTab: onActivateSourceTab,
), ),
); );
} }
@ -107,14 +102,8 @@ class ChatMessage extends StatelessWidget {
void onLongPressMessage(BuildContext context, TextStyle defaultTextStyle) { void onLongPressMessage(BuildContext context, TextStyle defaultTextStyle) {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
// Allow the full menu for live messages and for ones marked deleted/cleared
// by a moderation action — those keep their tags (user-id, id), so a
// moderator can still act on them (e.g. remove a timeout). Everything else
// (notices, system messages) just copies.
if (ircMessage.command != Command.privateMessage && if (ircMessage.command != Command.privateMessage &&
ircMessage.command != Command.userState && ircMessage.command != Command.userState) {
ircMessage.command != Command.clearChat &&
ircMessage.command != Command.clearMessage) {
copyMessage(); copyMessage();
return; return;
} }
@ -137,11 +126,7 @@ class ChatMessage extends StatelessWidget {
badgeScale: chatStore.settings.badgeScale, badgeScale: chatStore.settings.badgeScale,
launchExternal: chatStore.settings.launchUrlExternal, launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType, timestamp: chatStore.settings.timestampType,
forceTimestamp: forceTimestamp, channelIdToUserTwitch: overrideChannelIdToUserTwitch ??
showHistoricalTimestamps:
chatStore.settings.showHistoricalTimestamps,
channelIdToUserTwitch:
overrideChannelIdToUserTwitch ??
chatStore.assetsStore.channelIdToUserTwitch, chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId: currentChannelId:
overrideCurrentChannelId ?? chatStore.channelId, overrideCurrentChannelId ?? chatStore.channelId,
@ -206,14 +191,6 @@ class ChatMessage extends StatelessWidget {
leading: const Icon(Icons.reply), leading: const Icon(Icons.reply),
title: const Text('Reply to message'), title: const Text('Reply to message'),
), ),
if (ircMessage.tags['user-id'] != null)
ModerationActions(
chatStore: chatStore,
targetUserId: ircMessage.tags['user-id']!,
targetName:
ircMessage.tags['display-name'] ?? ircMessage.user ?? '',
messageId: ircMessage.tags['id'],
),
], ],
), ),
); );
@ -257,11 +234,7 @@ class ChatMessage extends StatelessWidget {
badgeScale: chatStore.settings.badgeScale, badgeScale: chatStore.settings.badgeScale,
launchExternal: chatStore.settings.launchUrlExternal, launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType, timestamp: chatStore.settings.timestampType,
forceTimestamp: forceTimestamp, channelIdToUserTwitch: overrideChannelIdToUserTwitch ??
showHistoricalTimestamps:
chatStore.settings.showHistoricalTimestamps,
channelIdToUserTwitch:
overrideChannelIdToUserTwitch ??
chatStore.assetsStore.channelIdToUserTwitch, chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId: currentChannelId:
overrideCurrentChannelId ?? chatStore.channelId, overrideCurrentChannelId ?? chatStore.channelId,
@ -416,12 +389,9 @@ class ChatMessage extends StatelessWidget {
}, },
launchExternal: chatStore.settings.launchUrlExternal, launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType, timestamp: chatStore.settings.timestampType,
forceTimestamp: forceTimestamp,
showHistoricalTimestamps:
chatStore.settings.showHistoricalTimestamps,
channelIdToUserTwitch: channelIdToUserTwitch:
overrideChannelIdToUserTwitch ?? overrideChannelIdToUserTwitch ??
chatStore.assetsStore.channelIdToUserTwitch, chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId: currentChannelId:
overrideCurrentChannelId ?? chatStore.channelId, overrideCurrentChannelId ?? chatStore.channelId,
), ),
@ -512,14 +482,10 @@ class ChatMessage extends StatelessWidget {
messageHeaderIcon = Icon( messageHeaderIcon = Icon(
Icons.campaign_rounded, Icons.campaign_rounded,
size: messageHeaderIconSize, size: messageHeaderIconSize,
color: messageHeaderTextColor,
); );
messageHeader = Text( messageHeader = const Text(
'Announcement', 'Announcement',
style: TextStyle( style: TextStyle(fontWeight: messageHeaderFontWeight),
fontWeight: messageHeaderFontWeight,
color: messageHeaderTextColor,
),
); );
} }
@ -548,12 +514,9 @@ class ChatMessage extends StatelessWidget {
badgeScale: chatStore.settings.badgeScale, badgeScale: chatStore.settings.badgeScale,
launchExternal: chatStore.settings.launchUrlExternal, launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType, timestamp: chatStore.settings.timestampType,
forceTimestamp: forceTimestamp,
showHistoricalTimestamps:
chatStore.settings.showHistoricalTimestamps,
channelIdToUserTwitch: channelIdToUserTwitch:
overrideChannelIdToUserTwitch ?? overrideChannelIdToUserTwitch ??
chatStore.assetsStore.channelIdToUserTwitch, chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId: currentChannelId:
overrideCurrentChannelId ?? chatStore.channelId, overrideCurrentChannelId ?? chatStore.channelId,
), ),
@ -626,15 +589,6 @@ class ChatMessage extends StatelessWidget {
child: dividedMessage, child: dividedMessage,
); );
final shouldFade = chatStore.shouldFadeMessage(
sourceChannelId: ircMessage.sourceChannelId,
overrideCurrentChannelId: overrideCurrentChannelId,
);
final fadedMessage = shouldFade
? Opacity(opacity: 0.55, child: coloredMessage)
: coloredMessage;
final finalMessage = InkWell( final finalMessage = InkWell(
onTap: () { onTap: () {
FocusScope.of(context).unfocus(); FocusScope.of(context).unfocus();
@ -643,7 +597,7 @@ class ChatMessage extends StatelessWidget {
} }
}, },
onLongPress: () => onLongPressMessage(context, defaultTextStyle), onLongPress: () => onLongPressMessage(context, defaultTextStyle),
child: fadedMessage, child: coloredMessage,
); );
return finalMessage; return finalMessage;

View file

@ -3,7 +3,6 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/models/user.dart';
import 'package:frosty/screens/channel/chat/chat.dart'; import 'package:frosty/screens/channel/chat/chat.dart';
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
import 'package:frosty/screens/channel/chat/widgets/add_chat_dialog.dart'; import 'package:frosty/screens/channel/chat/widgets/add_chat_dialog.dart';
@ -39,6 +38,7 @@ class ChatTabs extends StatelessWidget {
); );
if (!added && context.mounted) { if (!added && context.mounted) {
// If not added, it means the channel already exists (switched to it)
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Channel already open, switched to it'), content: Text('Channel already open, switched to it'),
@ -49,36 +49,23 @@ class ChatTabs extends StatelessWidget {
} }
} }
/// Wraps a tab bar item (a tab chip or a shared-chat bubble) with its
/// trailing gap and centering, keyed for the reorderable list.
Widget _wrapItem({required Key key, required bool isLast, required Widget child}) {
return Padding(
key: key,
padding: EdgeInsets.only(right: isLast ? 0 : 4),
child: Center(child: child),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Observer( return Observer(
builder: (context) { builder: (context) {
final tabs = chatTabsStore.tabs; final tabs = chatTabsStore.tabs;
final activeIndex = chatTabsStore.activeTabIndex; final activeIndex = chatTabsStore.activeTabIndex;
final activeStore = chatTabsStore.activeChatStore; final showTabBar = chatTabsStore.showTabBar;
// Twitch native shared chat participants (excluding the active final showMerge =
// channel itself, which is already shown as its own tab chip). tabs.where((t) => t.isActivated).length >= 2;
final sharedChatParticipants = activeStore.isInSharedChatMode
? activeStore.assetsStore.channelIdToUserTwitch.entries
.where((entry) => entry.key != activeStore.channelId)
.toList()
: const <MapEntry<String, UserTwitch>>[];
final showTabBar =
chatTabsStore.showTabBar || sharedChatParticipants.isNotEmpty;
final showMerge = tabs.where((t) => t.isActivated).length >= 2;
// Calculate extra top padding for tab bar when visible
final tabBarHeight = showTabBar ? 48.0 : 0.0; final tabBarHeight = showTabBar ? 48.0 : 0.0;
// Get the top inset from listPadding (e.g., for AppBar in chat-only mode)
final topInset = listPadding?.resolve(TextDirection.ltr).top ?? 0; final topInset = listPadding?.resolve(TextDirection.ltr).top ?? 0;
// Adjust list padding to account for tab bar
final adjustedPadding = listPadding != null final adjustedPadding = listPadding != null
? listPadding!.add(EdgeInsets.only(top: tabBarHeight)) ? listPadding!.add(EdgeInsets.only(top: tabBarHeight))
: EdgeInsets.only(top: tabBarHeight); : EdgeInsets.only(top: tabBarHeight);
@ -88,20 +75,27 @@ class ChatTabs extends StatelessWidget {
onPopInvokedWithResult: (didPop, _) { onPopInvokedWithResult: (didPop, _) {
if (didPop) return; if (didPop) return;
// Android back gesture: dismiss overlays before navigating away. // On Android back gesture, close overlays first before navigating back
final activeStore = chatTabsStore.activeChatStore; final activeStore = chatTabsStore.activeChatStore;
// Priority 1: Close emote menu
if (activeStore.assetsStore.showEmoteMenu) { if (activeStore.assetsStore.showEmoteMenu) {
activeStore.assetsStore.showEmoteMenu = false; activeStore.assetsStore.showEmoteMenu = false;
return; return;
} }
// Priority 2: Unfocus keyboard
if (activeStore.textFieldFocusNode.hasFocus) { if (activeStore.textFieldFocusNode.hasFocus) {
activeStore.unfocusInput(); activeStore.unfocusInput();
return; return;
} }
// Priority 3: Navigate back
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
child: Stack( child: Stack(
children: [ children: [
// Chat content: merged view or IndexedStack
Positioned.fill( Positioned.fill(
child: chatTabsStore.mergedMode child: chatTabsStore.mergedMode
? Chat( ? Chat(
@ -113,6 +107,7 @@ class ChatTabs extends StatelessWidget {
: IndexedStack( : IndexedStack(
index: activeIndex, index: activeIndex,
children: tabs.map((tabInfo) { children: tabs.map((tabInfo) {
// Show placeholder for non-activated tabs
if (tabInfo.chatStore == null) { if (tabInfo.chatStore == null) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
@ -125,6 +120,7 @@ class ChatTabs extends StatelessWidget {
}).toList(), }).toList(),
), ),
), ),
// Tab bar (only visible when more than 1 tab)
if (showTabBar) if (showTabBar)
Positioned( Positioned(
top: topInset, top: topInset,
@ -134,8 +130,8 @@ class ChatTabs extends StatelessWidget {
height: 48, height: 48,
child: Stack( child: Stack(
children: [ children: [
// Right-clip past the merge button so chips slide // When merge button is visible, clips at its center
// under its left half then disappear. // so chips slide under its left half then disappear.
Positioned( Positioned(
left: 0, left: 0,
top: 0, top: 0,
@ -143,19 +139,12 @@ class ChatTabs extends StatelessWidget {
right: showMerge ? 33 : 0, right: showMerge ? 33 : 0,
child: ReorderableListView.builder( child: ReorderableListView.builder(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: tabs.length + sharedChatParticipants.length, itemCount: tabs.length,
padding: EdgeInsets.only( padding: EdgeInsets.only(
left: 12, left: 12,
right: showMerge ? 32 : 12, right: showMerge ? 32 : 12,
), ),
onReorderItem: (oldIndex, newIndex) { onReorder: (oldIndex, newIndex) {
// Shared chat bubbles are trailing, fixed-order
// items (not real tabs) — ignore any reorder
// that touches them.
if (oldIndex >= tabs.length ||
newIndex >= tabs.length) {
return;
}
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
chatTabsStore.reorderTab(oldIndex, newIndex); chatTabsStore.reorderTab(oldIndex, newIndex);
}, },
@ -166,30 +155,13 @@ class ChatTabs extends StatelessWidget {
); );
}, },
itemBuilder: (context, index) { itemBuilder: (context, index) {
final isLast = final tabInfo = chatTabsStore.tabs[index];
index == return Padding(
tabs.length + key: ValueKey(tabInfo.channelId),
sharedChatParticipants.length - padding: EdgeInsets.only(
1; right: index < tabs.length - 1 ? 4 : 0,
if (index < tabs.length) {
final tabInfo = chatTabsStore.tabs[index];
return _wrapItem(
key: ValueKey(tabInfo.channelId),
isLast: isLast,
child: _buildTab(context, index),
);
}
final participant =
sharedChatParticipants[index - tabs.length];
return _wrapItem(
key: ValueKey('shared_${participant.key}'),
isLast: isLast,
child: _buildSharedChatBubble(
context,
participant.key,
participant.value,
), ),
child: _buildTab(context, index),
); );
}, },
), ),
@ -199,9 +171,7 @@ class ChatTabs extends StatelessWidget {
right: 12, right: 12,
top: 0, top: 0,
bottom: 0, bottom: 0,
child: Center( child: Center(child: _buildMergeToggle(context)),
child: _buildMoreActionsMenu(context),
),
), ),
], ],
), ),
@ -214,47 +184,22 @@ class ChatTabs extends StatelessWidget {
); );
} }
Widget _buildMoreActionsMenu(BuildContext context) { Widget _buildMergeToggle(BuildContext context) {
final settings = chatTabsStore.activeChatStore.settings; final isMerged = chatTabsStore.mergedMode;
return IconButton.filledTonal(
return _AnchoredPopupMenu( icon: const Icon(Icons.call_merge, size: 18),
itemsBuilder: (close) => [ tooltip: isMerged ? 'Split chats' : 'Merge loaded chats',
Observer( visualDensity: VisualDensity.compact,
builder: (_) => CheckboxMenuButton( isSelected: isMerged,
value: chatTabsStore.mergedMode, style: IconButton.styleFrom(
onChanged: (_) { minimumSize: const Size(42, 42),
HapticFeedback.selectionClick(); backgroundColor: isMerged
chatTabsStore.toggleMergedMode(); ? null
}, : Theme.of(context).colorScheme.surfaceContainerHighest,
child: const Text('Merge chats'), ),
), onPressed: () {
), HapticFeedback.selectionClick();
Observer( chatTabsStore.toggleMergedMode();
builder: (_) => CheckboxMenuButton(
value: settings.focusCurrentChannel,
onChanged: (newValue) {
HapticFeedback.selectionClick();
settings.focusCurrentChannel = newValue ?? false;
},
child: const Text('Focus current channel'),
),
),
],
anchorBuilder: (context, toggle) {
return IconButton.filledTonal(
icon: const Icon(Icons.more_vert, size: 18),
tooltip: 'Chat options',
visualDensity: VisualDensity.compact,
style: IconButton.styleFrom(
minimumSize: const Size(42, 42),
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
),
onPressed: () {
HapticFeedback.selectionClick();
toggle();
},
);
}, },
); );
} }
@ -262,281 +207,86 @@ class ChatTabs extends StatelessWidget {
Widget _buildTab(BuildContext context, int index) { Widget _buildTab(BuildContext context, int index) {
final tabInfo = chatTabsStore.tabs[index]; final tabInfo = chatTabsStore.tabs[index];
final isActive = index == chatTabsStore.activeTabIndex; final isActive = index == chatTabsStore.activeTabIndex;
final isSecondary = !tabInfo.isPrimary; final isActivated = tabInfo.isActivated;
final displayName = getReadableName( final displayName = getReadableName(
tabInfo.displayName, tabInfo.displayName,
tabInfo.channelLogin, tabInfo.channelLogin,
); );
return Observer( final avatar = ProfilePicture(userLogin: tabInfo.channelLogin, radius: 12);
builder: (context) {
final theme = Theme.of(context);
final isActivated = tabInfo.isActivated;
final hasUnread = chatTabsStore.hasUnreadMessages(index);
final dimmed = !isActivated || !(tabInfo.chatStore?.isConnected ?? false);
InputChip buildChip({VoidCallback? onDeleted}) => InputChip( if (tabInfo.isPrimary) {
avatar: Badge( return InputChip(
smallSize: 8, avatar: avatar,
backgroundColor: theme.colorScheme.primary, label: Text(displayName),
isLabelVisible: hasUnread, selected: isActive,
child: Opacity( showCheckmark: false,
opacity: dimmed ? 0.5 : 1.0, visualDensity: VisualDensity.compact,
child: ProfilePicture( materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
userLogin: tabInfo.channelLogin, onPressed: () {
radius: 12, if (!isActive) {
), HapticFeedback.selectionClick();
), chatTabsStore.setActiveTab(index);
), }
label: Text(
displayName,
style: dimmed
? TextStyle(
color: theme.textTheme.bodyMedium?.color
?.withValues(alpha: 0.5),
)
: null,
),
selected: isActive,
showCheckmark: false,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onPressed: () {
if (isActive) return;
HapticFeedback.selectionClick();
chatTabsStore.setActiveTab(index);
},
onDeleted: onDeleted,
deleteButtonTooltipMessage:
onDeleted != null ? 'Tab options' : null,
);
if (!isSecondary) return buildChip();
return _AnchoredPopupMenu(
itemsBuilder: (close) => [
if (isActivated)
MenuItemButton(
leadingIcon: const Icon(Icons.power_off_rounded, size: 18),
child: const Text('Disconnect'),
onPressed: () {
HapticFeedback.lightImpact();
close();
chatTabsStore.deactivateTab(index);
},
),
MenuItemButton(
leadingIcon: Icon(
Icons.delete_outline_rounded,
size: 18,
color: theme.colorScheme.error,
),
child: Text(
'Remove',
style: TextStyle(color: theme.colorScheme.error),
),
onPressed: () {
HapticFeedback.lightImpact();
close();
chatTabsStore.removeTab(index);
},
),
],
anchorBuilder: (context, toggle) =>
buildChip(onDeleted: toggle),
);
},
);
}
/// Builds a bubble for a Twitch native shared chat participant channel.
/// Unlike manual tabs, these don't own a connection: tapping spotlights
/// the channel (unfades it, fading the rest — see [chat_message.dart]),
/// and the trailing menu hides/shows the channel's messages instead of
/// disconnecting/removing a tab.
Widget _buildSharedChatBubble(
BuildContext context,
String channelId,
UserTwitch user,
) {
final activeStore = chatTabsStore.activeChatStore;
final displayName = getReadableName(user.displayName, user.login);
return Observer(
builder: (context) {
final theme = Theme.of(context);
final isFocused = activeStore.sharedChatBubbles.isFocused(channelId);
final isHidden = activeStore.sharedChatBubbles.isHidden(channelId);
InputChip buildChip({VoidCallback? onDeleted}) => InputChip(
avatar: Opacity(
opacity: isHidden ? 0.5 : 1.0,
child: ProfilePicture(userLogin: user.login, radius: 12),
),
label: Text(
displayName,
style: isHidden
? TextStyle(
color: theme.textTheme.bodyMedium?.color
?.withValues(alpha: 0.5),
)
: null,
),
selected: isFocused,
showCheckmark: false,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onPressed: () {
HapticFeedback.selectionClick();
activeStore.sharedChatBubbles.toggleFocus(channelId);
},
onDeleted: onDeleted,
deleteButtonTooltipMessage:
onDeleted != null ? 'Channel options' : null,
);
return _AnchoredPopupMenu(
itemsBuilder: (close) => [
MenuItemButton(
leadingIcon: Icon(
isHidden
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
size: 18,
),
child: Text(isHidden ? 'Show channel' : 'Hide channel'),
onPressed: () {
HapticFeedback.lightImpact();
close();
if (isHidden) {
activeStore.sharedChatBubbles.show(channelId);
} else {
activeStore.sharedChatBubbles.hide(channelId);
}
},
),
],
anchorBuilder: (context, toggle) => buildChip(onDeleted: toggle),
);
},
);
}
}
/// Lightweight popup menu anchored to a child widget. The menu's top-right
/// aligns to the anchor's bottom-right via [CompositedTransformFollower] and
/// fades + scales in from that corner. [MenuAnchor] doesn't expose an entry
/// animation hook in this Flutter version, so we drive one ourselves.
class _AnchoredPopupMenu extends StatefulWidget {
final Widget Function(BuildContext context, VoidCallback toggle)
anchorBuilder;
final List<Widget> Function(VoidCallback close) itemsBuilder;
const _AnchoredPopupMenu({
required this.anchorBuilder,
required this.itemsBuilder,
});
@override
State<_AnchoredPopupMenu> createState() => _AnchoredPopupMenuState();
}
class _AnchoredPopupMenuState extends State<_AnchoredPopupMenu>
with SingleTickerProviderStateMixin {
final _link = LayerLink();
final _portalCtrl = OverlayPortalController();
late final _anim = AnimationController(
duration: const Duration(milliseconds: 150),
reverseDuration: const Duration(milliseconds: 100),
vsync: this,
)..addStatusListener((status) {
if (status == AnimationStatus.dismissed && _portalCtrl.isShowing) {
_portalCtrl.hide();
}
});
late final _curve =
CurvedAnimation(parent: _anim, curve: Curves.easeOut);
late final _scale = Tween<double>(begin: 0.92, end: 1.0).animate(_curve);
@override
void dispose() {
_curve.dispose();
_anim.dispose();
super.dispose();
}
void _toggle() {
if (_portalCtrl.isShowing) {
_anim.reverse();
} else {
_portalCtrl.show();
_anim.forward();
}
}
void _close() {
if (_portalCtrl.isShowing) _anim.reverse();
}
@override
Widget build(BuildContext context) {
return CompositedTransformTarget(
link: _link,
child: OverlayPortal(
controller: _portalCtrl,
overlayChildBuilder: (overlayContext) {
final theme = Theme.of(overlayContext);
return Stack(
children: [
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _close,
),
),
CompositedTransformFollower(
link: _link,
targetAnchor: Alignment.bottomRight,
followerAnchor: Alignment.topRight,
offset: const Offset(0, 4),
showWhenUnlinked: false,
child: FadeTransition(
opacity: _anim,
child: ScaleTransition(
scale: _scale,
alignment: Alignment.topRight,
child: Material(
color: theme.colorScheme.surface,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
side: BorderSide(
color: theme.colorScheme.outlineVariant
.withValues(alpha: 0.5),
width: 0.5,
),
),
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: IntrinsicWidth(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: widget.itemsBuilder(_close),
),
),
),
),
),
),
),
],
);
}, },
child: widget.anchorBuilder(context, _toggle), );
), }
return MenuAnchor(
menuChildren: [
if (isActivated)
MenuItemButton(
leadingIcon: const Icon(Icons.power_off_rounded, size: 18),
child: const Text('Disconnect'),
onPressed: () {
HapticFeedback.lightImpact();
chatTabsStore.deactivateTab(index);
},
),
MenuItemButton(
leadingIcon: Icon(
Icons.delete_outline_rounded,
size: 18,
color: Theme.of(context).colorScheme.error,
),
child: Text(
'Remove',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
onPressed: () {
HapticFeedback.lightImpact();
chatTabsStore.removeTab(index);
},
),
],
builder: (context, controller, child) {
return InputChip(
avatar:
isActivated ? avatar : Opacity(opacity: 0.5, child: avatar),
label: Text(
displayName,
style: isActivated
? null
: TextStyle(
color: Theme.of(
context,
).textTheme.bodyMedium?.color?.withValues(alpha: 0.5),
),
),
selected: isActive,
showCheckmark: false,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onPressed: () {
if (!isActive) {
HapticFeedback.selectionClick();
chatTabsStore.setActiveTab(index);
}
},
onDeleted: () => controller.open(),
deleteButtonTooltipMessage: 'Tab options',
);
},
); );
} }
} }

View file

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
import 'package:frosty/screens/channel/chat/widgets/moderation_actions.dart';
import 'package:frosty/utils.dart'; import 'package:frosty/utils.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart'; import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:frosty/widgets/alert_message.dart'; import 'package:frosty/widgets/alert_message.dart';
@ -15,7 +14,6 @@ class ChatUserModal extends StatefulWidget {
final String username; final String username;
final String displayName; final String displayName;
final String userId; final String userId;
final VoidCallback? onActivateSourceTab;
const ChatUserModal({ const ChatUserModal({
super.key, super.key,
@ -23,7 +21,6 @@ class ChatUserModal extends StatefulWidget {
required this.username, required this.username,
required this.displayName, required this.displayName,
required this.userId, required this.userId,
this.onActivateSourceTab,
}); });
@override @override
@ -64,7 +61,6 @@ class _ChatUserModalState extends State<ChatUserModal> {
IconButton( IconButton(
tooltip: 'Reply', tooltip: 'Reply',
onPressed: () { onPressed: () {
widget.onActivateSourceTab?.call();
widget.chatStore.textController.text = widget.chatStore.textController.text =
'@${widget.username} '; '@${widget.username} ';
Navigator.pop(context); Navigator.pop(context);
@ -81,13 +77,6 @@ class _ChatUserModalState extends State<ChatUserModal> {
name: name, name: name,
userLogin: widget.username, userLogin: widget.username,
userId: widget.userId, userId: widget.userId,
// Moderator actions for this user (no messageId → no
// Delete); renders nothing for non-moderators.
moderationActions: ModerationActions(
chatStore: widget.chatStore,
targetUserId: widget.userId,
targetName: name,
),
), ),
), ),
icon: Icon(Icons.adaptive.more_rounded), icon: Icon(Icons.adaptive.more_rounded),
@ -126,7 +115,6 @@ class _ChatUserModalState extends State<ChatUserModal> {
ircMessage: userMessages[index], ircMessage: userMessages[index],
chatStore: widget.chatStore, chatStore: widget.chatStore,
isModal: true, isModal: true,
forceTimestamp: true,
), ),
), ),
), ),

View file

@ -1,212 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/onboarding/login_webview.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
/// Moderator actions (delete / timeout / ban, plus unban) for [targetUserId],
/// reused by the message long-press menu and the user panel.
///
/// Renders nothing unless the viewer can moderate the channel, or should see
/// an opt-in row when IRC/broadcaster hints mod capability without OAuth scopes.
/// [messageId] enables the per-message Delete action; omit it (e.g. the user
/// panel, which isn't tied to one message) to hide Delete. The unban action
/// only appears when the user is currently banned/timed-out (seen via CLEARCHAT).
class ModerationActions extends StatelessWidget {
final ChatStore chatStore;
final String targetUserId;
/// Display name used in confirmation notifications.
final String targetName;
/// The message to delete; null hides the Delete action.
final String? messageId;
const ModerationActions({
super.key,
required this.chatStore,
required this.targetUserId,
required this.targetName,
this.messageId,
});
@override
Widget build(BuildContext context) {
return Observer(
builder: (context) {
final auth = chatStore.auth;
final hasModScopes = auth.hasModeratorScopes;
if (hasModScopes) {
if (auth.user.canPerformModeratorActions(chatStore.channelId)) {
return _actionsColumn(context);
}
return const SizedBox.shrink();
}
// IRC/broadcaster is a CTA hint only — not authority to call Helix.
// Read userState.mod only on the no-scopes path so USERSTATE acks do
// not rebuild this sheet while real actions are already available.
final isBroadcaster = auth.user.details?.id == chatStore.channelId;
final ircModHint = chatStore.userState.mod;
if (isBroadcaster || ircModHint) {
return _enableModeratorToolsRow(context, auth);
}
return const SizedBox.shrink();
},
);
}
Widget _enableModeratorToolsRow(BuildContext context, AuthStore auth) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Divider(),
ListTile(
onTap: () {
Navigator.pop(context);
// Use the root navigator after closing the sheet.
openEnableModeratorTools(auth);
},
leading: const Icon(Icons.shield_outlined),
title: const Text('Enable moderator tools…'),
subtitle: const Text(
'Authorize Twitch permissions to delete, timeout, and ban.',
),
),
],
);
}
Widget _actionsColumn(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Divider(),
// Compact one-row punitive actions, ordered least- to most-destructive.
Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 8),
child: Row(
children: [
if (messageId != null) ...[
Expanded(
child: _button(
Icons.delete_outline,
'Delete',
() => _run(context, _delete),
),
),
const SizedBox(width: 8),
],
Expanded(
child: _button(
Icons.timer_outlined,
'Timeout 10min',
() => _run(context, _timeout),
),
),
const SizedBox(width: 8),
Expanded(
child: _button(
Icons.block,
'Ban',
() => _run(context, _ban),
),
),
],
),
),
// Recovery action, separate from the punitive row to avoid mistaps and
// only shown when the user is actually banned/timed-out.
if (chatStore.isUserBannedOrTimedOut(targetUserId))
ListTile(
onTap: () => _run(context, _unban),
leading: const Icon(Icons.lock_open_rounded),
title: const Text('Remove ban or timeout'),
),
],
);
}
/// Compact icon-over-label button.
Widget _button(IconData icon, String label, VoidCallback onTap) {
return OutlinedButton(
onPressed: onTap,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 10),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 20),
const SizedBox(height: 4),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(label, style: const TextStyle(fontSize: 12)),
),
],
),
);
}
/// Closes the sheet, then runs [action], surfacing Twitch's reason on failure.
Future<void> _run(
BuildContext context,
Future<void> Function() action,
) async {
Navigator.pop(context);
try {
await action();
} on ApiException catch (e) {
chatStore.updateNotification(e.message);
}
}
Future<void> _delete() async {
final success = await chatStore.auth.user.deleteMessage(
broadcasterId: chatStore.channelId,
messageId: messageId!,
);
chatStore.updateNotification(
success ? 'Message deleted' : 'Failed to delete message',
);
}
Future<void> _timeout() async {
final success = await chatStore.auth.user.banOrTimeoutUser(
broadcasterId: chatStore.channelId,
userIdToBan: targetUserId,
duration: 600, // 10 minutes
);
chatStore.updateNotification(
success
? '$targetName timed out for 10 minutes.'
: 'Failed to timeout user',
);
}
Future<void> _ban() async {
final success = await chatStore.auth.user.banOrTimeoutUser(
broadcasterId: chatStore.channelId,
userIdToBan: targetUserId,
);
chatStore.updateNotification(
success ? '$targetName banned.' : 'Failed to ban user',
);
}
Future<void> _unban() async {
final success = await chatStore.auth.user.unbanUser(
broadcasterId: chatStore.channelId,
userIdToUnban: targetUserId,
);
if (success) chatStore.clearUserBanState(targetUserId);
chatStore.updateNotification(
success
? 'Removed ban/timeout for $targetName.'
: 'Failed to remove ban or timeout',
);
}
}

View file

@ -61,7 +61,7 @@ class ReplyThread extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: Text( child: Text(
'$replyName: $replyBody', '$replyName: $replyBody',
style: const TextStyle(fontWeight: FontWeight.w600), style: const TextStyle(fontWeight: FontWeight.w500),
), ),
), ),
Flexible( Flexible(

View file

@ -1,63 +0,0 @@
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,
),
],
);
},
),
),
);
}
}

View file

@ -1,32 +0,0 @@
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
/// Single owner of `SettingsStore.syncedChatDelay` writes from the video
/// player stores. Previously both stores poked the setting directly,
/// making settings an ad-hoc event bus between video and chat.
class ChatLatencySync {
ChatLatencySync({required this.settingsStore});
final SettingsStore settingsStore;
/// Reset the delay to zero on stream transitions (channel enter, stream
/// end, chat-only toggle, etc). No-op when auto-sync is disabled.
void reset() {
if (settingsStore.autoSyncChatDelay) {
settingsStore.syncedChatDelay = 0.0;
}
}
/// Push a fresh video-latency measurement. Only writes when drift from
/// the current value exceeds the tolerance, to avoid thrashing the chat
/// delay countdown on minor fluctuations.
void report(double latencySeconds) {
if (!settingsStore.autoSyncChatDelay) return;
final current = settingsStore.syncedChatDelay;
if (current == 0 ||
(latencySeconds - current).abs() >
VideoTimingConstants.chatSyncDriftToleranceSeconds) {
settingsStore.syncedChatDelay = latencySeconds;
}
}
}

View file

@ -1,106 +0,0 @@
import 'package:better_native_video_player/better_native_video_player.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/video/native_video_store.dart';
/// Native video player widget backed by AVPlayerViewController (iOS)
/// and ExoPlayer (Android).
class NativeVideo extends StatelessWidget {
final NativeVideoStore nativeVideoStore;
const NativeVideo({super.key, required this.nativeVideoStore});
@override
Widget build(BuildContext context) {
return Observer(
builder: (context) {
final controller = nativeVideoStore.controller;
final error = nativeVideoStore.error;
final isOffline =
!nativeVideoStore.loading &&
nativeVideoStore.streamInfo == null &&
error == null;
return ColoredBox(
color: Colors.black,
child: Stack(
children: [
if (controller != null && !isOffline)
NativeVideoPlayer(
key: ObjectKey(controller),
controller: controller,
),
if (controller != null && !nativeVideoStore.hasPlayedOnce && !isOffline)
const ColoredBox(
color: Colors.black,
child: SizedBox.expand(),
),
if (error != null)
Align(
alignment: const Alignment(0, 0.3),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline_rounded,
color: Colors.amber.withValues(alpha: 0.7),
size: 32,
),
const SizedBox(height: 10),
Text(
error,
style: const TextStyle(
color: Colors.white60,
fontSize: 13,
height: 1.4,
),
textAlign: TextAlign.center,
),
],
),
),
),
if (isOffline)
const Center(
child: Icon(
Icons.tv_off_rounded,
color: Colors.white24,
size: 32,
),
),
if (nativeVideoStore.isAudioOnlyMode && !isOffline)
const ColoredBox(
color: Colors.black,
child: SizedBox.expand(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.headphones_rounded,
color: Colors.white24,
size: 32,
),
SizedBox(height: 8),
Text(
'Audio Only',
style: TextStyle(
color: Colors.white24,
fontSize: 13,
),
),
],
),
),
),
),
],
),
);
},
);
}
}

View file

@ -1,8 +0,0 @@
import 'package:frosty/screens/channel/video/video_player_interface.dart';
/// Capabilities that only the native player can meaningfully support.
/// Callers that need these must type-check against this interface.
abstract class NativeVideoPlayerInterface extends VideoPlayerInterface {
/// Whether the current quality is an HLS audio-only variant.
bool get isAudioOnlyMode;
}

File diff suppressed because it is too large Load diff

View file

@ -1,517 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'native_video_store.dart';
// **************************************************************************
// StoreGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
mixin _$NativeVideoStore on NativeVideoStoreBase, Store {
Computed<String>? _$streamQualityComputed;
@override
String get streamQuality => (_$streamQualityComputed ??= Computed<String>(
() => super.streamQuality,
name: 'NativeVideoStoreBase.streamQuality',
)).value;
late final _$_controllerAtom = Atom(
name: 'NativeVideoStoreBase._controller',
context: context,
);
NativeVideoPlayerController? get controller {
_$_controllerAtom.reportRead();
return super._controller;
}
@override
NativeVideoPlayerController? get _controller => controller;
@override
set _controller(NativeVideoPlayerController? value) {
_$_controllerAtom.reportWrite(value, super._controller, () {
super._controller = value;
});
}
late final _$_loadingAtom = Atom(
name: 'NativeVideoStoreBase._loading',
context: context,
);
bool get loading {
_$_loadingAtom.reportRead();
return super._loading;
}
@override
bool get _loading => loading;
@override
set _loading(bool value) {
_$_loadingAtom.reportWrite(value, super._loading, () {
super._loading = value;
});
}
late final _$_blockingAdsAtom = Atom(
name: 'NativeVideoStoreBase._blockingAds',
context: context,
);
bool get blockingAds {
_$_blockingAdsAtom.reportRead();
return super._blockingAds;
}
@override
bool get _blockingAds => blockingAds;
@override
set _blockingAds(bool value) {
_$_blockingAdsAtom.reportWrite(value, super._blockingAds, () {
super._blockingAds = value;
});
}
late final _$_pausedAtom = Atom(
name: 'NativeVideoStoreBase._paused',
context: context,
);
bool get paused {
_$_pausedAtom.reportRead();
return super._paused;
}
@override
bool get _paused => paused;
@override
set _paused(bool value) {
_$_pausedAtom.reportWrite(value, super._paused, () {
super._paused = value;
});
}
late final _$_hasPlayedOnceAtom = Atom(
name: 'NativeVideoStoreBase._hasPlayedOnce',
context: context,
);
bool get hasPlayedOnce {
_$_hasPlayedOnceAtom.reportRead();
return super._hasPlayedOnce;
}
@override
bool get _hasPlayedOnce => hasPlayedOnce;
@override
set _hasPlayedOnce(bool value) {
_$_hasPlayedOnceAtom.reportWrite(value, super._hasPlayedOnce, () {
super._hasPlayedOnce = value;
});
}
late final _$_overlayVisibleAtom = Atom(
name: 'NativeVideoStoreBase._overlayVisible',
context: context,
);
bool get overlayVisible {
_$_overlayVisibleAtom.reportRead();
return super._overlayVisible;
}
@override
bool get _overlayVisible => overlayVisible;
@override
set _overlayVisible(bool value) {
_$_overlayVisibleAtom.reportWrite(value, super._overlayVisible, () {
super._overlayVisible = value;
});
}
late final _$_streamInfoAtom = Atom(
name: 'NativeVideoStoreBase._streamInfo',
context: context,
);
StreamTwitch? get streamInfo {
_$_streamInfoAtom.reportRead();
return super._streamInfo;
}
@override
StreamTwitch? get _streamInfo => streamInfo;
@override
set _streamInfo(StreamTwitch? value) {
_$_streamInfoAtom.reportWrite(value, super._streamInfo, () {
super._streamInfo = value;
});
}
late final _$_offlineChannelInfoAtom = Atom(
name: 'NativeVideoStoreBase._offlineChannelInfo',
context: context,
);
Channel? get offlineChannelInfo {
_$_offlineChannelInfoAtom.reportRead();
return super._offlineChannelInfo;
}
@override
Channel? get _offlineChannelInfo => offlineChannelInfo;
@override
set _offlineChannelInfo(Channel? value) {
_$_offlineChannelInfoAtom.reportWrite(value, super._offlineChannelInfo, () {
super._offlineChannelInfo = value;
});
}
late final _$_availableStreamQualitiesAtom = Atom(
name: 'NativeVideoStoreBase._availableStreamQualities',
context: context,
);
List<String> get availableStreamQualities {
_$_availableStreamQualitiesAtom.reportRead();
return super._availableStreamQualities;
}
@override
List<String> get _availableStreamQualities => availableStreamQualities;
@override
set _availableStreamQualities(List<String> value) {
_$_availableStreamQualitiesAtom.reportWrite(
value,
super._availableStreamQualities,
() {
super._availableStreamQualities = value;
},
);
}
late final _$_restrictedStreamQualitiesAtom = Atom(
name: 'NativeVideoStoreBase._restrictedStreamQualities',
context: context,
);
List<String> get restrictedStreamQualities {
_$_restrictedStreamQualitiesAtom.reportRead();
return super._restrictedStreamQualities;
}
@override
List<String> get _restrictedStreamQualities => restrictedStreamQualities;
@override
set _restrictedStreamQualities(List<String> value) {
_$_restrictedStreamQualitiesAtom.reportWrite(
value,
super._restrictedStreamQualities,
() {
super._restrictedStreamQualities = value;
},
);
}
late final _$_streamQualityIndexAtom = Atom(
name: 'NativeVideoStoreBase._streamQualityIndex',
context: context,
);
int get streamQualityIndex {
_$_streamQualityIndexAtom.reportRead();
return super._streamQualityIndex;
}
@override
int get _streamQualityIndex => streamQualityIndex;
@override
set _streamQualityIndex(int value) {
_$_streamQualityIndexAtom.reportWrite(value, super._streamQualityIndex, () {
super._streamQualityIndex = value;
});
}
late final _$_latencyAtom = Atom(
name: 'NativeVideoStoreBase._latency',
context: context,
);
String? get latency {
_$_latencyAtom.reportRead();
return super._latency;
}
@override
String? get _latency => latency;
@override
set _latency(String? value) {
_$_latencyAtom.reportWrite(value, super._latency, () {
super._latency = value;
});
}
late final _$_isInPipModeAtom = Atom(
name: 'NativeVideoStoreBase._isInPipMode',
context: context,
);
bool get isInPipMode {
_$_isInPipModeAtom.reportRead();
return super._isInPipMode;
}
@override
bool get _isInPipMode => isInPipMode;
@override
set _isInPipMode(bool value) {
_$_isInPipModeAtom.reportWrite(value, super._isInPipMode, () {
super._isInPipMode = value;
});
}
late final _$_isAudioOnlyModeAtom = Atom(
name: 'NativeVideoStoreBase._isAudioOnlyMode',
context: context,
);
bool get isAudioOnlyMode {
_$_isAudioOnlyModeAtom.reportRead();
return super._isAudioOnlyMode;
}
@override
bool get _isAudioOnlyMode => isAudioOnlyMode;
@override
set _isAudioOnlyMode(bool value) {
_$_isAudioOnlyModeAtom.reportWrite(value, super._isAudioOnlyMode, () {
super._isAudioOnlyMode = value;
});
}
late final _$_errorAtom = Atom(
name: 'NativeVideoStoreBase._error',
context: context,
);
String? get error {
_$_errorAtom.reportRead();
return super._error;
}
@override
String? get _error => error;
@override
set _error(String? value) {
_$_errorAtom.reportWrite(value, super._error, () {
super._error = value;
});
}
late final _$_initPlayerAsyncAction = AsyncAction(
'NativeVideoStoreBase._initPlayer',
context: context,
);
@override
Future<void> _initPlayer() {
return _$_initPlayerAsyncAction.run(() => super._initPlayer());
}
late final _$updateStreamQualitiesAsyncAction = AsyncAction(
'NativeVideoStoreBase.updateStreamQualities',
context: context,
);
@override
Future<void> updateStreamQualities() {
return _$updateStreamQualitiesAsyncAction.run(
() => super.updateStreamQualities(),
);
}
late final _$setStreamQualityAsyncAction = AsyncAction(
'NativeVideoStoreBase.setStreamQuality',
context: context,
);
@override
Future<void> setStreamQuality(String quality) {
return _$setStreamQualityAsyncAction.run(
() => super.setStreamQuality(quality),
);
}
late final _$_setStreamQualityIndexAsyncAction = AsyncAction(
'NativeVideoStoreBase._setStreamQualityIndex',
context: context,
);
@override
Future<void> _setStreamQualityIndex(int index) {
return _$_setStreamQualityIndexAsyncAction.run(
() => super._setStreamQualityIndex(index),
);
}
late final _$updateStreamInfoAsyncAction = AsyncAction(
'NativeVideoStoreBase.updateStreamInfo',
context: context,
);
@override
Future<void> updateStreamInfo({bool forceUpdate = false}) {
return _$updateStreamInfoAsyncAction.run(
() => super.updateStreamInfo(forceUpdate: forceUpdate),
);
}
late final _$NativeVideoStoreBaseActionController = ActionController(
name: 'NativeVideoStoreBase',
context: context,
);
@override
Future<void> _reloadCurrentController() {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
name: 'NativeVideoStoreBase._reloadCurrentController',
);
try {
return super._reloadCurrentController();
} finally {
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void handleVideoTap() {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
name: 'NativeVideoStoreBase.handleVideoTap',
);
try {
return super.handleVideoTap();
} finally {
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void handleToggleOverlay() {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
name: 'NativeVideoStoreBase.handleToggleOverlay',
);
try {
return super.handleToggleOverlay();
} finally {
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
Future<void> handleRefresh() {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
name: 'NativeVideoStoreBase.handleRefresh',
);
try {
return super.handleRefresh();
} finally {
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void togglePictureInPicture() {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
name: 'NativeVideoStoreBase.togglePictureInPicture',
);
try {
return super.togglePictureInPicture();
} finally {
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void handleAppResume() {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
name: 'NativeVideoStoreBase.handleAppResume',
);
try {
return super.handleAppResume();
} finally {
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void handleAndroidPipChanged(bool isInPip) {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
name: 'NativeVideoStoreBase.handleAndroidPipChanged',
);
try {
return super.handleAndroidPipChanged(isInPip);
} finally {
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void dispose() {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
name: 'NativeVideoStoreBase.dispose',
);
try {
return super.dispose();
} finally {
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void _resetPlayerStateForChatOnly() {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
name: 'NativeVideoStoreBase._resetPlayerStateForChatOnly',
);
try {
return super._resetPlayerStateForChatOnly();
} finally {
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void _initFreshController() {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
name: 'NativeVideoStoreBase._initFreshController',
);
try {
return super._initFreshController();
} finally {
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''
streamQuality: ${streamQuality}
''';
}
}

View file

@ -1,27 +0,0 @@
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;
}

View file

@ -1,86 +0,0 @@
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/models/channel.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
/// Snapshot of a channel's live state.
class StreamInfoResult {
const StreamInfoResult({this.stream, this.offlineChannel});
/// Non-null when the channel is currently live.
final StreamTwitch? stream;
/// Non-null when the channel is confirmed offline (channel metadata fetched
/// successfully but no live stream). Both fields null means the fetch
/// failed entirely (network error, etc.).
final Channel? offlineChannel;
}
/// Debounced, deduplicated Twitch stream-info fetcher shared by both video
/// stores. Each store still owns its observable `_streamInfo` /
/// `_offlineChannelInfo` fields and decides how to react to the result.
class StreamInfoPoller {
StreamInfoPoller({
required this.twitchApi,
required this.userLogin,
required this.userId,
});
final TwitchApi twitchApi;
final String userLogin;
final String userId;
DateTime? _lastUpdate;
Future<StreamInfoResult>? _inFlight;
/// Fetches the latest stream info, returning `null` if the call was
/// debounced (caller should skip updating state). Concurrent calls await
/// the in-flight request instead of issuing a new one.
Future<StreamInfoResult?> fetch({bool forceUpdate = false}) async {
final pending = _inFlight;
if (pending != null) return pending;
final now = DateTime.now();
if (!forceUpdate && _lastUpdate != null) {
if (now.difference(_lastUpdate!) <
VideoTimingConstants.streamInfoDebounce) {
return null;
}
}
_lastUpdate = now;
final request = _runFetch();
_inFlight = request;
try {
return await request;
} finally {
if (identical(_inFlight, request)) _inFlight = null;
}
}
Future<StreamInfoResult> _runFetch() async {
try {
final info = await twitchApi.getStream(userLogin: userLogin);
return StreamInfoResult(stream: info);
} catch (_) {
// Helix can transiently drop a live stream from /streams (or 500 on
// one endpoint while others succeed). A false "confirmed offline"
// tears down latency polling and chat delay on healthy playback, so
// require a second failed check before falling back to /channels.
try {
await Future<void>.delayed(
VideoTimingConstants.offlineConfirmationDelay,
);
final info = await twitchApi.getStream(userLogin: userLogin);
return StreamInfoResult(stream: info);
} catch (_) {
try {
final channel = await twitchApi.getChannel(userId: userId);
return StreamInfoResult(offlineChannel: channel);
} catch (_) {
return const StreamInfoResult();
}
}
}
}
}

View file

@ -3,12 +3,10 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/screens/channel/chat/details/chat_users_list.dart'; import 'package:frosty/screens/channel/chat/details/chat_users_list.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/video/native_video_store.dart';
import 'package:frosty/screens/channel/video/stream_info_bar.dart'; import 'package:frosty/screens/channel/video/stream_info_bar.dart';
import 'package:frosty/screens/channel/video/video_player_interface.dart'; import 'package:frosty/screens/channel/video/video_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/theme.dart'; import 'package:frosty/theme.dart';
import 'package:frosty/utils.dart'; import 'package:frosty/utils.dart';
@ -23,7 +21,7 @@ import 'package:shared_preferences/shared_preferences.dart';
/// Creates a widget containing controls which enable interactions with an underlying [Video] widget. /// Creates a widget containing controls which enable interactions with an underlying [Video] widget.
class VideoOverlay extends StatelessWidget { class VideoOverlay extends StatelessWidget {
final VideoPlayerInterface videoStore; final VideoStore videoStore;
final ChatStore chatStore; final ChatStore chatStore;
final SettingsStore settingsStore; final SettingsStore settingsStore;
@ -90,7 +88,7 @@ class VideoOverlay extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SectionHeader( const SectionHeader(
'Quality', 'Stream quality',
padding: EdgeInsets.fromLTRB(16, 0, 16, 8), padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
isFirst: true, isFirst: true,
), ),
@ -99,35 +97,26 @@ class VideoOverlay extends StatelessWidget {
builder: (context) => ListView( builder: (context) => ListView(
shrinkWrap: true, shrinkWrap: true,
primary: false, primary: false,
children: [ children: videoStore.availableStreamQualities
...videoStore.availableStreamQualities.map( .map(
(quality) => ListTile( (quality) => ListTile(
trailing: videoStore.streamQuality == quality trailing: videoStore.streamQuality == quality
? const Icon(Icons.check_rounded) ? const Icon(Icons.check_rounded)
: null, : null,
title: Text(quality), title: Text(quality),
onTap: () { onTap: () {
videoStore.setStreamQuality(quality); videoStore.setStreamQuality(quality);
SharedPreferences.getInstance().then( SharedPreferences.getInstance().then(
(prefs) => prefs.setString( (prefs) => prefs.setString(
lastStreamQualityKey(videoStore.userLogin), 'last_stream_quality',
quality, quality,
), ),
); );
Navigator.pop(context); Navigator.pop(context);
}, },
), ),
), )
// Qualities the broadcaster offers only to subscribers .toList(),
// (from the playback token's restricted_bitrates).
...videoStore.restrictedStreamQualities.map(
(quality) => ListTile(
enabled: false,
title: Text(quality),
subtitle: const Text('Subscribers only'),
),
),
],
), ),
), ),
), ),
@ -270,6 +259,7 @@ class VideoOverlay extends StatelessWidget {
displayName: chatStore.displayName, displayName: chatStore.displayName,
showUptime: false, showUptime: false,
showViewerCount: false, showViewerCount: false,
showOfflineIndicator: false,
textColor: surfaceColor, textColor: surfaceColor,
isOffline: true, isOffline: true,
isInSharedChatMode: isInSharedChatMode:
@ -322,25 +312,6 @@ 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: [
@ -373,36 +344,32 @@ class VideoOverlay extends StatelessWidget {
if (videoStore.settingsStore.fullScreen && if (videoStore.settingsStore.fullScreen &&
context.isLandscape) context.isLandscape)
chatOverlayButton, chatOverlayButton,
if (!Platform.isIOS || if (!Platform.isIOS || isIPad()) videoSettingsButton,
isIPad() ||
videoStore is NativeVideoStore)
videoSettingsButton,
], ],
), ),
if (!videoStore.loading) Center(
Center( child: Tooltip(
child: Tooltip( message: videoStore.paused ? 'Play' : 'Pause',
message: videoStore.paused ? 'Play' : 'Pause', preferBelow: false,
preferBelow: false, child: IconButton(
child: IconButton( iconSize: 56,
iconSize: 56, icon: Icon(
icon: Icon( videoStore.paused
videoStore.paused ? Icons.play_arrow_rounded
? Icons.play_arrow_rounded : Icons.pause_rounded,
: Icons.pause_rounded, color: surfaceColor,
color: surfaceColor, shadows: [
shadows: [ Shadow(
Shadow( offset: const Offset(0, 3),
offset: const Offset(0, 3), blurRadius: 8,
blurRadius: 8, color: Colors.black.withValues(alpha: 0.6),
color: Colors.black.withValues(alpha: 0.6), ),
), ],
],
),
onPressed: videoStore.handlePausePlay,
), ),
onPressed: videoStore.handlePausePlay,
), ),
), ),
),
Align( Align(
alignment: Alignment.bottomLeft, alignment: Alignment.bottomLeft,
child: Row( child: Row(

View file

@ -1,51 +0,0 @@
import 'package:frosty/models/channel.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
/// Abstraction over the video player store so both [VideoStore] (WebView)
/// and [NativeVideoStore] can drive the same overlay and channel UI.
///
/// Native-only capabilities live on [NativeVideoPlayerInterface].
abstract class VideoPlayerInterface {
SettingsStore get settingsStore;
/// Channel this store is driving — used as a key for per-channel
/// persistence (e.g. last-selected quality).
String get userLogin;
bool get loading;
bool get paused;
bool get overlayVisible;
bool get isInPipMode;
StreamTwitch? get streamInfo;
Channel? get offlineChannelInfo;
List<String> get availableStreamQualities;
/// Qualities that exist for this stream but require a subscription.
/// Only the native player can know this (parsed from the playback token);
/// the WebView player always reports none.
List<String> get restrictedStreamQualities;
String get streamQuality;
String? get latency;
/// True while TwitchAdSolutions is actively avoiding an ad break.
bool get isBlockingAds;
void handleVideoTap();
void handlePausePlay();
void handleToggleOverlay();
Future<void> handleRefresh();
void requestPictureInPicture();
void togglePictureInPicture();
Future<void> updateStreamQualities();
Future<void> setStreamQuality(String quality);
Future<void> updateStreamInfo({bool forceUpdate});
void handleAppResume();
/// Called from the platform PiP callbacks on Android so the store can sync
/// its overlay state, and its playback, with the OS PiP window.
void handleAndroidPipChanged(bool isInPip);
void dispose();
}

File diff suppressed because it is too large Load diff

View file

@ -9,26 +9,6 @@ part of 'video_store.dart';
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers // ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
mixin _$VideoStore on VideoStoreBase, Store { mixin _$VideoStore on VideoStoreBase, Store {
late final _$_loadingAtom = Atom(
name: 'VideoStoreBase._loading',
context: context,
);
bool get loading {
_$_loadingAtom.reportRead();
return super._loading;
}
@override
bool get _loading => loading;
@override
set _loading(bool value) {
_$_loadingAtom.reportWrite(value, super._loading, () {
super._loading = value;
});
}
late final _$_pausedAtom = Atom( late final _$_pausedAtom = Atom(
name: 'VideoStoreBase._paused', name: 'VideoStoreBase._paused',
context: context, context: context,
@ -278,30 +258,6 @@ 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(

View file

@ -1,67 +0,0 @@
/// Named durations and counts used by the video player stores.
/// Central owner so tuning values don't have to be hunted across files.
class VideoTimingConstants {
VideoTimingConstants._();
// Stall recovery
//
// Cross-layer ordering contract: iOS's native notification-stall recovery
// fires at ~1s, this Dart timer at 8s, and the Android native watchdog
// (VideoPlayerObserver.kt STALL_WATCHDOG_MS) at 12s. Correctness depends
// on 1s < stallDetectionDelay < 12s — Dart's attempt-1 seek must pre-empt
// the Android watchdog's heavier stop()/prepare() recovery, and iOS has
// no native watchdog so the Dart timer is the only recoverer for silent
// rate-drops there. Don't tune past either neighbor.
static const stallDetectionDelay = Duration(seconds: 8);
static const initRetryDelay = Duration(seconds: 3);
static const int maxRefreshAttempts = 3;
/// Delay between stall-recovery full refreshes, indexed by attempt number
/// (0 = first attempt). Attempts beyond the list length fall through to
/// the error path via [maxRefreshAttempts].
static const refreshBackoff = <Duration>[
Duration.zero,
Duration(seconds: 3),
Duration(seconds: 8),
];
/// A "play" event arriving within this window after a previous one is
/// treated as a failed-recovery bounce rather than a fresh successful start.
static const shortPlayWindow = Duration(seconds: 10);
/// Number of short-play bounces required before surfacing a tailored error.
static const int shortPlayLoopThreshold = 3;
// Latency / chat sync
static const latencyPollingInterval = Duration(seconds: 3);
static const int highLatencyThresholdSeconds = 30;
static const int chatSyncDriftToleranceSeconds = 2;
/// Maximum consecutive high-latency interventions (seek/refresh) before
/// giving up on latency-driven recovery. Persistent high readings usually
/// mean device clock skew or a device that can't keep up — looping
/// recoveries forever makes playback worse, not better.
static const int maxHighLatencyRecoveries = 3;
/// How long a quality switch may suppress stall classification before the
/// flag expires. A resolution-only switch never interrupts playback, so no
/// play/pause event arrives to clear it — without this backstop the flag
/// sticks for the whole session and disables stall recovery.
static const qualitySwitchGrace = Duration(seconds: 5);
/// Delay before re-checking /streams when the first check reports offline.
/// Helix transiently drops live streams from results; a single failed
/// check isn't enough to tear down healthy playback.
static const offlineConfirmationDelay = Duration(seconds: 2);
// Overlay
static const overlayAutoHide = Duration(seconds: 5);
static const overlayQuickHide = Duration(seconds: 3);
// Stream info
static const streamInfoDebounce = Duration(seconds: 5);
static const webviewStreamInfoInterval = Duration(minutes: 1);
static const nativeStreamInfoInterval = Duration(seconds: 30);
// WebView housekeeping
static const jsCleanupInterval = Duration(minutes: 10);
}

View file

@ -166,12 +166,13 @@ class _HomeState extends State<Home> {
NavigationDestination( NavigationDestination(
icon: Icon( icon: Icon(
Icons.favorite_border_rounded, Icons.favorite_border_rounded,
color: theme.colorScheme.onSurfaceVariant color: _homeStore.selectedIndex == 0
.withValues(alpha: 0.5), ? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant,
), ),
selectedIcon: Icon( selectedIcon: Icon(
Icons.favorite_rounded, Icons.favorite_rounded,
color: theme.colorScheme.primary, color: theme.colorScheme.onSurface,
), ),
label: 'Following', label: 'Following',
tooltip: 'Following', tooltip: 'Following',
@ -179,12 +180,15 @@ class _HomeState extends State<Home> {
NavigationDestination( NavigationDestination(
icon: Icon( icon: Icon(
Icons.arrow_upward_rounded, Icons.arrow_upward_rounded,
color: theme.colorScheme.onSurfaceVariant color:
.withValues(alpha: 0.5), _homeStore.selectedIndex ==
(_authStore.isLoggedIn ? 1 : 0)
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant,
), ),
selectedIcon: Icon( selectedIcon: Icon(
Icons.arrow_upward_rounded, Icons.arrow_upward_rounded,
color: theme.colorScheme.primary, color: theme.colorScheme.onSurface,
), ),
label: 'Top', label: 'Top',
tooltip: 'Top', tooltip: 'Top',
@ -192,12 +196,15 @@ class _HomeState extends State<Home> {
NavigationDestination( NavigationDestination(
icon: Icon( icon: Icon(
Icons.search_rounded, Icons.search_rounded,
color: theme.colorScheme.onSurfaceVariant color:
.withValues(alpha: 0.5), _homeStore.selectedIndex ==
(_authStore.isLoggedIn ? 2 : 1)
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant,
), ),
selectedIcon: Icon( selectedIcon: Icon(
Icons.search_rounded, Icons.search_rounded,
color: theme.colorScheme.primary, color: theme.colorScheme.onSurface,
), ),
label: 'Search', label: 'Search',
tooltip: 'Search', tooltip: 'Search',

View file

@ -2,10 +2,8 @@ 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';
@ -15,7 +13,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 contains search history and search results for channels and categories. /// The search section that contians 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;
@ -33,7 +31,6 @@ 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
@ -174,20 +171,6 @@ 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(),
], ],
@ -221,7 +204,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, categories, or tags', hintText: 'Search channels or categories',
suffixIcon: suffixIcon:
_searchStore.textFieldFocusNode.hasFocus || _searchStore.textFieldFocusNode.hasFocus ||
_searchStore.searchText.isNotEmpty _searchStore.searchText.isNotEmpty

View file

@ -1,97 +0,0 @@
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),
),
),
);
}
}

View file

@ -2,7 +2,6 @@ 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';
@ -18,8 +17,6 @@ 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();
@ -43,20 +40,13 @@ abstract class SearchStoreBase with Store {
@readonly @readonly
ObservableFuture<CategoriesTwitch?>? _categoryFuture; ObservableFuture<CategoriesTwitch?>? _categoryFuture;
@readonly SearchStoreBase({required this.authStore, required this.twitchApi}) {
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 {
// Load SharedPreferences for local search history. // Retrieve the instance that will allow us to retrieve 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.
@ -85,7 +75,6 @@ abstract class SearchStoreBase with Store {
_isSearching = false; _isSearching = false;
_channelFuture = null; _channelFuture = null;
_categoryFuture = null; _categoryFuture = null;
_tagFuture = null;
return; return;
} }
@ -138,8 +127,6 @@ 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].

View file

@ -109,26 +109,6 @@ 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,

View file

@ -1,83 +0,0 @@
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,
),
),
],
),
),
),
),
],
),
);
}
}

View file

@ -20,8 +20,6 @@ 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,
@ -30,8 +28,6 @@ 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
@ -99,8 +95,6 @@ class LargeStreamCard extends StatelessWidget {
userId: streamInfo.userId, userId: streamInfo.userId,
showPinOption: showPinOption, showPinOption: showPinOption,
isPinned: isPinned, isPinned: isPinned,
initialIsFollowing: initialIsFollowing,
onFollowChanged: onFollowChanged,
), ),
); );
}, },

View file

@ -15,8 +15,6 @@ 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,
@ -24,8 +22,6 @@ 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
@ -81,8 +77,6 @@ class OfflineChannelCard extends StatelessWidget {
userId: channelInfo.broadcasterId, userId: channelInfo.broadcasterId,
showPinOption: showPinOption, showPinOption: showPinOption,
isPinned: isPinned, isPinned: isPinned,
initialIsFollowing: initialIsFollowing,
onFollowChanged: onFollowChanged,
), ),
); );
}, },

View file

@ -26,8 +26,6 @@ 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,
@ -36,8 +34,6 @@ 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
@ -228,8 +224,6 @@ class StreamCard extends StatelessWidget {
userId: streamInfo.userId, userId: streamInfo.userId,
showPinOption: showPinOption, showPinOption: showPinOption,
isPinned: isPinned, isPinned: isPinned,
initialIsFollowing: initialIsFollowing,
onFollowChanged: onFollowChanged,
), ),
); );
}, },

View file

@ -3,7 +3,6 @@ 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';
@ -24,41 +23,19 @@ 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;
@ -67,8 +44,7 @@ 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 => bool get hasMore => isLoading == false && _streamsCursor != null;
isLoading == false && (_streamsCursor != null || _helixFill);
@computed @computed
bool get isLoading => bool get isLoading =>
@ -77,12 +53,6 @@ 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.
@ -175,22 +145,24 @@ 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(_onScroll); scrollController!.addListener(() {
if (scrollController!.position.atEdge ||
scrollController!.position.outOfRange) {
showJumpButton = false;
} else {
showJumpButton = true;
}
});
} }
if (listType == ListType.followed) { if (listType == ListType.followed) {
@ -223,241 +195,54 @@ 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 languages = _languageFilter; final StreamsTwitch newStreams;
final isAsc = sortOrder == StreamViewerSort.viewersAsc; switch (listType) {
final useGql = _preferGqlDirectory; case ListType.followed:
_lastPageWasGql = false; newStreams = await twitchApi.getFollowedStreams(
id: authStore.user.details!.id,
StreamsTwitch? newStreams; cursor: _streamsCursor,
if (listType == ListType.followed) { );
newStreams = await twitchApi.getFollowedStreams( break;
id: authStore.user.details!.id, case ListType.top:
cursor: _streamsCursor, newStreams = await twitchApi.getTopStreams(cursor: _streamsCursor);
); break;
} else if (useGql) { case ListType.category:
newStreams = await _fetchGqlDirectory(languages: languages); newStreams = await twitchApi.getStreamsUnderCategory(
if (newStreams != null) { gameId: categoryId!,
_lastPageWasGql = true; cursor: _streamsCursor,
} );
break;
} }
// Helix: Top/category DESC, or GQL unavailable / integrity fallback.
newStreams ??= await twitchApi.getDirectoryStreams(
languages: languages,
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) { if (_streamsCursor == null) {
_helixFill = false; _allStreams = newStreams.data.asObservable();
} else if (!_lastPageWasGql && isAsc) { } else {
_helixFill = true; _allStreams.addAll(newStreams.data);
} }
_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;
} }
}
/// GQL directory page with integrity retry. Returns null to trigger Helix. _isAllStreamsLoading = false;
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
@ -535,8 +320,6 @@ abstract class ListStoreBase with Store {
} }
_streamsCursor = null; _streamsCursor = null;
_helixFill = false;
_lastPageWasGql = false;
await getStreams(); await getStreams();
} }
@ -566,14 +349,8 @@ abstract class ListStoreBase with Store {
void dispose() { void dispose() {
_pinnedStreamsReactionDisposer?.call(); _pinnedStreamsReactionDisposer?.call();
_offlineChannelsExpansionDisposer?.call(); _offlineChannelsExpansionDisposer?.call();
_languageFilterReactionDisposer?.call();
if (scrollController != null) { scrollController?.dispose();
scrollController!.removeListener(_onScroll);
if (ownsScrollController) {
scrollController!.dispose();
}
}
} }
} }
@ -582,5 +359,4 @@ 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.
/// [ListType.tag] is streams matching a freeform tag (GQL sort + tags; Helix fallback). enum ListType { followed, top, category }
enum ListType { followed, top, category, tag }

View file

@ -56,24 +56,6 @@ 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,
@ -320,16 +302,6 @@ 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,
@ -377,7 +349,6 @@ 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},

View file

@ -2,7 +2,6 @@ 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';
@ -26,12 +25,6 @@ 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;
@ -41,8 +34,6 @@ 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,
}); });
@ -72,24 +63,16 @@ 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() {
// ListStore disposes the controller when it owns it. _scrollController?.dispose();
if (!_listStore.ownsScrollController) {
_scrollController?.dispose();
}
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_listStore.dispose();
super.dispose(); super.dispose();
} }
@ -112,8 +95,6 @@ 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;
@ -228,12 +209,9 @@ class _StreamsListState extends State<StreamsList>
); );
} else { } else {
statusWidget = AlertMessage( statusWidget = AlertMessage(
message: switch (widget.listType) { message: widget.listType == ListType.followed
ListType.followed => 'No followed streams', ? 'No followed streams'
ListType.tag => 'No streams with this tag', : 'No top streams',
ListType.category => 'No streams in this category',
ListType.top => 'No top streams',
},
vertical: true, vertical: true,
); );
} }
@ -260,12 +238,6 @@ 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,
@ -326,9 +298,6 @@ 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),
@ -340,9 +309,6 @@ class _StreamsListState extends State<StreamsList>
widget.categoryId == null, widget.categoryId == null,
showPinOption: true, showPinOption: true,
isPinned: true, isPinned: true,
initialIsFollowing: true,
onFollowChanged:
onFollowChanged,
); );
}, },
); );
@ -385,9 +351,6 @@ 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(
@ -399,9 +362,6 @@ 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,
), ),
); );
}, },
@ -450,8 +410,6 @@ class _StreamsListState extends State<StreamsList>
_listStore.offlineChannels[index], _listStore.offlineChannels[index],
showPinOption: false, showPinOption: false,
isPinned: false, isPinned: false,
initialIsFollowing: true,
onFollowChanged: onFollowChanged,
), ),
); );
}, },

Some files were not shown because too many files have changed in this diff Show more