Compare commits

..

6 commits

Author SHA1 Message Date
Tommy Chow
21d388fa02 enhance layout and styling in home screen and app bar 2025-06-26 01:11:14 -04:00
Tommy Chow
2008d2c688 update styles 2025-06-25 21:56:33 -04:00
Tommy Chow
56961c7010 fix pinned streams not showing in other lists 2025-06-18 21:12:03 -04:00
Tommy Chow
29c84858a0 clean up tooltips 2025-06-18 19:47:32 -04:00
Tommy Chow
a2d30ec086 simplify settings 2025-06-18 19:46:57 -04:00
Tommy Chow
60a97e6a24 un-center appbar 2025-06-17 17:06:04 -04:00
314 changed files with 11072 additions and 46125 deletions

View file

@ -1,5 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
@../AGENTS.md

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,2 +0,0 @@
CLIENT_ID=
SECRET=

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

@ -1,33 +0,0 @@
version: 2
updates:
# Keep GitHub Actions up to date
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "ci"
groups:
actions:
patterns:
- "*"
# Keep Ruby gems (fastlane) up to date
- package-ecosystem: "bundler"
directory: "/android"
schedule:
interval: "monthly"
- package-ecosystem: "bundler"
directory: "/ios"
schedule:
interval: "monthly"
# Keep Dart/Flutter packages up to date
- package-ecosystem: "pub"
directory: "/"
schedule:
interval: "weekly"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]

View file

@ -3,57 +3,18 @@ on:
push:
branches: [main]
jobs:
# Linux runner: analyze + test once (both are OS-independent) and compile a
# release APK to catch release-only breakage. Debug-signed via the build.gradle
# fallback (ALLOW_DEBUG_SIGNED_RELEASE) — no signing secrets needed.
android:
name: Analyze, test, and build Android
runs-on: ubuntu-latest
test:
name: Check and test on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- 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: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version-file: pubspec.yaml
cache: true
- run: flutter doctor -v
- 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 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

@ -19,11 +19,11 @@ jobs:
SECRET: ${{ secrets.SECRET }}
steps:
- name: Checkout Frosty repo
uses: actions/checkout@v6
uses: actions/checkout@v4
# Setup Java so that we can build the APK.
- name: Set up Java
uses: actions/setup-java@v5
uses: actions/setup-java@v3
with:
distribution: temurin
java-version: 21
@ -32,7 +32,6 @@ jobs:
- 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.
@ -53,53 +52,10 @@ jobs:
flutter build apk --dart-define CLIENT_ID=$CLIENT_ID --dart-define SECRET=$SECRET
mv build/app/outputs/flutter-apk/app-release.apk frosty-${{ github.ref_name }}.apk
# Extract release notes from assets/release-notes.md for the current version.
- name: Extract release notes
id: release_notes
run: |
VERSION="${{ github.ref_name }}"
RELEASE_NOTES_FILE="assets/release-notes.md"
# Extract the section for this version using awk
# Starts at "## vX.X.X", skips date line, stops at next "---" or "## v" header
NOTES=$(awk -v version="$VERSION" '
BEGIN { found=0; output="" }
$0 ~ "^## " version "$" { found=1; next }
found && /^[A-Z][a-z]+ [0-9]+, [0-9]+$/ { next }
found && /^---$/ { exit }
found && /^## v[0-9]+\.[0-9]+\.[0-9]+$/ { exit }
found { output = output $0 "\n" }
END { print output }
' "$RELEASE_NOTES_FILE")
# Clean up "on GitHub" suffix (redundant since we're on GitHub)
NOTES=$(echo "$NOTES" | sed 's/ on GitHub)/)/')
# Trim leading/trailing whitespace
NOTES=$(echo "$NOTES" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
if [ -n "$NOTES" ]; then
# Get the previous tag for the Full Changelog link
PREV_TAG=$(git describe --tags --abbrev=0 "$VERSION^" 2>/dev/null || echo "")
echo "found=true" >> $GITHUB_OUTPUT
echo "body<<EOF" >> $GITHUB_OUTPUT
echo "$NOTES" >> $GITHUB_OUTPUT
if [ -n "$PREV_TAG" ]; then
echo "" >> $GITHUB_OUTPUT
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREV_TAG}...${VERSION}" >> $GITHUB_OUTPUT
fi
echo "EOF" >> $GITHUB_OUTPUT
else
echo "found=false" >> $GITHUB_OUTPUT
echo "::warning::No release notes found for $VERSION in $RELEASE_NOTES_FILE"
fi
# Create a draft release on GitHub.
- name: Release
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@v1
with:
draft: true
generate_release_notes: ${{ steps.release_notes.outputs.found != 'true' }}
body: ${{ steps.release_notes.outputs.found == 'true' && steps.release_notes.outputs.body || '' }}
generate_release_notes: true
files: frosty-${{ github.ref_name }}.apk

View file

@ -7,37 +7,11 @@ jobs:
name: Check and test
runs-on: ubuntu-latest
steps:
- 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: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version-file: pubspec.yaml
cache: true
- run: flutter doctor -v
- 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 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

@ -29,18 +29,18 @@ jobs:
steps:
- name: Checkout Frosty repo
uses: actions/checkout@v6
uses: actions/checkout@v4
# 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
ruby-version: 3.2
bundler-cache: true
# Setup Java so that we can build the appbundle.
- name: Set up Java
uses: actions/setup-java@v5
uses: actions/setup-java@v3
with:
distribution: temurin
java-version: 21
@ -49,7 +49,6 @@ jobs:
- 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.
@ -83,9 +82,9 @@ jobs:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
steps:
- name: Checkout Frosty repo
uses: actions/checkout@v6
uses: actions/checkout@v4
# Always pick the newest production Xcode on the runner.
# Always pick the newest production Xcode on the runner
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
@ -94,14 +93,13 @@ jobs:
- name: Set up Ruby and Bundler
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.3
ruby-version: 3.2
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).

11
.gitignore vendored
View file

@ -21,7 +21,7 @@ migrate_working_dir/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
# .vscode/
.vscode/
# Flutter/Dart/Pub related
**/doc/api/
@ -32,7 +32,6 @@ migrate_working_dir/
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
@ -44,8 +43,6 @@ app.*.map.json
/android/app/debug
/android/app/profile
/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
random/
@ -65,9 +62,3 @@ random/
# potentially sensitive review information
**/fastlane/metadata/review_information
# secrets
.env
# ai
.claude/settings.local.json

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"]
}
}
}

25
.vscode/launch.json vendored
View file

@ -1,25 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "frosty",
"request": "launch",
"type": "dart",
"args": ["--dart-define-from-file=.env"]
},
{
"name": "frosty (profile mode)",
"request": "launch",
"type": "dart",
"flutterMode": "profile",
"args": ["--dart-define-from-file=.env"]
},
{
"name": "frosty (release mode)",
"request": "launch",
"type": "dart",
"flutterMode": "release",
"args": ["--dart-define-from-file=.env"]
}
]
}

View file

@ -1,46 +0,0 @@
# AGENTS.md
Flutter Twitch client for iOS and Android. MobX with `build_runner`. The landing page is `web/` (own `AGENTS.md`). Flutter is pinned in `pubspec.yaml` `environment.flutter` — bump that one field; CI reads it via `flutter-version-file`.
## Commands
```bash
flutter pub get
flutter run --dart-define-from-file=.env # local; .env is gitignored
flutter run --dart-define=CLIENT_ID=ID --dart-define=SECRET=SECRET
flutter analyze
flutter test
flutter test test/path/to/file.dart
dart run build_runner build # after MobX / @JsonSerializable changes
```
`--delete-conflicting-outputs` is ignored as of build_runner 2.15. Don't add it.
## Layout
- `lib/apis/` — API clients extend `BaseApiClient`
- `lib/models/` — data models; `@JsonSerializable` ones commit `.g.dart`
- `lib/screens/{feature}/` — screens; feature stores in co-located `stores/`
- `lib/stores/` — global stores only (`global_assets_store.dart`)
- `test/` mirrors `lib/`; shared fixtures in `test/fixtures/`
Don't add top-level store files or standalone API classes. Canonical MobX store: `lib/screens/settings/stores/auth_store.dart`. Extract state, reactions, and actions into stores when it keeps widgets on rendering.
New or changed logic (stores, API clients, parsing, utils) ships with tests; extend existing tests when touching tested code. Pure UI or config may skip. HTTP: `http_mock_adapter` (`DioAdapter`) with full URLs. Otherwise `mocktail`.
## Gotchas
- Commit regenerated `.g.dart` files. Never edit them. CI fails the PR if they're stale.
- `main.dart` secure-storage `deleteAll()` looks redundant; it handles Android/iOS storage surviving uninstall. Don't remove it.
- `flutter_secure_storage` stays on v9 (v10 shipped in 5.0.0 and was reverted in 5.0.1 over an Android 14 startup freeze). That also holds `package_info_plus` v9 and `device_info_plus` v12 (win32). Don't jump them in a drive-by upgrade.
- Git-pinned forks in `pubspec.yaml` (`extended_text_field`, `better_native_video_player`, `simple_pip_mode`) carry patches. Don't swap them back to pub.dev.
- `android/gradle.properties`: keep `android.builtInKotlin=false` and `android.newDsl=false` so plugins that still apply KGP build on AGP 9.
- `ios/Runner/AppDelegate.swift` is customized (`CookieExtractorPlugin` + `FlutterImplicitEngineDelegate`). Flutter will not auto-migrate UIScene; don't replace the delegate with the template.
- `android/.../MainActivity.kt` extends `PipCallbackHelperActivityWrapper` and registers `CookieExtractorPlugin` by hand. Don't revert it to `FlutterActivity`.
- Helix OAuth (`Bearer`) and the Twitch web GQL cookie are different tokens. Native playback uses the cookie; don't send the Helix token to GQL/Usher.
- `syncedChatDelay` is runtime-only (`includeFromJson/toJson: false`). Don't persist it with the other settings.
- Don't remove the `ALLOW_DEBUG_SIGNED_RELEASE` branch in `android/app/build.gradle.kts`. Real releases must still fail if `key.properties` is missing.
## Releases
When cutting a store or GitHub release, read `docs/release.md` and follow it. That playbook is repo-local, not a global skill. `assets/release-notes.md` keeps about three consecutive versions; drop from the oldest end, never skip.

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

@ -85,17 +85,11 @@ For a more detailed overview, visit [frostyapp.io](https://www.frostyapp.io/).
3. Go to the [Twitch dev console](https://dev.twitch.tv/login) and register a new application to retrieve a **Client ID** and **Client Secret**.
4. Copy `.env.example` to `.env` and fill in your credentials:
```bash
cp .env.example .env
```
4. Use [`--dart-define`](https://dartcode.org/docs/using-dart-define-in-flutter/) to set the `clientId` and `secret` environment variables with your **Client ID** and **Client Secret**.
5. Run `flutter pub get` to fetch all the dependencies.
6. Choose an emulator or device and run the app! The included VS Code launch configurations will automatically read your `.env` file.
7. (Optional) Run `dart run scripts/get_twitch_token.dart` to quickly get a user auth token. Long-press the **Anonymous** account tile in the app to log in.
6. Choose an emulator or device and run the app!
> [!IMPORTANT]
> Frosty uses [MobX](https://mobx.netlify.app/) for state management. Please refer to the documentation about code generation, otherwise your changes within MobX stores may not be applied.

View file

@ -11,14 +11,6 @@ include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- lib/**.g.dart
- .dart_tool/**
- build/**
- ios/**
- android/**
- web/**
- windows/**
- macos/**
- linux/**
linter:
# The lint rules applied to this project can be customized in the

View file

@ -1,49 +1,48 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
CFPropertyList (3.0.7)
base64
nkf
rexml
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1260.0)
aws-sdk-core (3.252.0)
aws-eventstream (1.3.2)
aws-partitions (1.1082.0)
aws-sdk-core (3.222.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.129.0)
aws-sdk-core (~> 3, >= 3.248.0)
aws-sdk-kms (1.99.0)
aws-sdk-core (~> 3, >= 3.216.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.225.1)
aws-sdk-core (~> 3, >= 3.248.0)
aws-sdk-s3 (1.183.0)
aws-sdk-core (~> 3, >= 3.216.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-sigv4 (1.11.0)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.3.0)
benchmark (0.5.0)
bigdecimal (4.1.2)
base64 (0.2.0)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.5)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.112.0)
faraday (1.10.5)
excon (0.109.0)
faraday (1.10.4)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
@ -55,36 +54,32 @@ GEM
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.8)
faraday-cookie_jar (0.0.7)
faraday (>= 0.8.0)
http-cookie (>= 1.0.0)
http-cookie (~> 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.2.0)
faraday-multipart (1.1.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.4)
faraday-retry (1.0.3)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.1)
fastlane (2.234.0)
CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1)
fastimage (2.4.0)
fastlane (2.227.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.197)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2)
benchmark (>= 0.1.0)
bundler (>= 1.17.3, < 5.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
@ -92,24 +87,20 @@ GEM
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.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)
google-apis-androidpublisher_v3 (~> 0.3)
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)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3)
naturally (~> 2.2)
nkf (~> 0.2)
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
@ -120,44 +111,43 @@ GEM
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty (~> 0.4.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)
google-apis-androidpublisher_v3 (0.102.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.3)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
google-apis-iamcredentials_v1 (0.27.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.63.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.9.0)
rexml
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.29.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.6.1)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.1.1)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.6.0)
google-cloud-storage (1.61.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.1)
google-cloud-storage (1.45.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.29.0)
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.11.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.1)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
@ -168,41 +158,42 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.19.9)
jwt (2.10.3)
json (2.7.6)
jwt (2.10.1)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.21.1)
multi_json (1.15.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
nkf (0.3.0)
optparse (0.8.1)
naturally (2.2.1)
nkf (0.2.0)
optparse (0.6.0)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
public_suffix (7.0.5)
rake (13.4.2)
public_suffix (5.1.1)
rake (13.2.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.8.0)
rexml (3.4.4)
retriable (3.1.2)
rexml (3.4.1)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.22.0)
signet (0.18.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
@ -212,6 +203,7 @@ GEM
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unf (0.2.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.27.0)

60
android/app/build.gradle Normal file
View file

@ -0,0 +1,60 @@
plugins {
id "com.android.application"
// START: FlutterFire Configuration
id 'com.google.gms.google-services'
id 'com.google.firebase.firebase-perf'
id 'com.google.firebase.crashlytics'
// END: FlutterFire Configuration
id "kotlin-android"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin"
}
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
namespace = "com.tommychow.frosty"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8
}
defaultConfig {
applicationId = "com.tommychow.frosty"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
release {
keyAlias keystoreProperties["keyAlias"]
keyPassword keystoreProperties["keyPassword"]
storeFile keystoreProperties["storeFile"] ? file(keystoreProperties["storeFile"]) : null
storePassword keystoreProperties["storePassword"]
}
}
buildTypes {
release {
signingConfig signingConfigs.release
}
}
}
flutter {
source = "../.."
}

View file

@ -1,70 +0,0 @@
import java.io.FileInputStream
import java.util.Properties
plugins {
id("com.android.application")
id("com.google.gms.google-services")
id("com.google.firebase.firebase-perf")
id("com.google.firebase.crashlytics")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
namespace = "com.tommychow.frosty"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
applicationId = "com.tommychow.frosty"
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String?
keyPassword = keystoreProperties["keyPassword"] as String?
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
storePassword = keystoreProperties["storePassword"] as String?
}
}
buildTypes {
release {
// Use the real upload key when key.properties is present (release
// workflows create it from secrets). Allow debug signing only when a CI
// compile-only check explicitly opts in via ALLOW_DEBUG_SIGNED_RELEASE;
// otherwise fail loudly so a real release can never be silently
// debug-signed if the keystore step is ever missing.
signingConfig = when {
keystorePropertiesFile.exists() -> signingConfigs.getByName("release")
System.getenv("ALLOW_DEBUG_SIGNED_RELEASE") != null -> signingConfigs.getByName("debug")
else -> error("key.properties missing — set ALLOW_DEBUG_SIGNED_RELEASE=1 for compile-only builds")
}
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}

View file

@ -13,8 +13,7 @@
android:label="Frosty"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules">
android:allowBackup="false">
<activity
android:name=".MainActivity"
android:exported="true"
@ -37,16 +36,10 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"/>
<data android:host="twitch.tv" />
<data android:host="www.twitch.tv" />
<data android:host="m.twitch.tv" />
</intent-filter>
</activity>
<meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="false" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

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
import cl.puntito.simple_pip_mode.PipCallbackHelperActivityWrapper
import io.flutter.embedding.engine.FlutterEngine
class MainActivity: PipCallbackHelperActivityWrapper() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(CookieExtractorPlugin())
}
}

View file

@ -4,7 +4,7 @@
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.

View file

@ -7,7 +7,7 @@
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.

View file

@ -4,7 +4,7 @@
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.

View file

@ -7,7 +7,7 @@
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.

View file

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" path="."/>
</cloud-backup>
<device-transfer>
<exclude domain="root" path="."/>
</device-transfer>
</data-extraction-rules>

18
android/build.gradle Normal file
View file

@ -0,0 +1,18 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = "../build"
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

View file

@ -1,37 +0,0 @@
import com.android.build.api.variant.LibraryAndroidComponentsExtension
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
// AGP 9 requires each library to compile against at least the compileSdk of
// its dependencies. Several Flutter plugins still pin compileSdk 33/34.
// finalizeDsl runs after the plugin's android {} block. Does not change minSdk.
subprojects {
pluginManager.withPlugin("com.android.library") {
extensions.configure<LibraryAndroidComponentsExtension>("androidComponents") {
finalizeDsl { it.compileSdk = 36 }
}
}
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View file

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

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

View file

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-all.zip

28
android/settings.gradle Normal file
View file

@ -0,0 +1,28 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.2.1" apply false
id "org.jetbrains.kotlin.android" version "2.0.21" apply false
id "com.google.gms.google-services" version "4.3.15" apply false
id "com.google.firebase.firebase-perf" version "1.4.2" apply false
id "com.google.firebase.crashlytics" version "2.9.9" apply false
}
include ":app"

View file

@ -1,28 +0,0 @@
pluginManagement {
val flutterSdkPath = run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.1.0" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
id("com.google.gms.google-services") version "4.4.4" apply false
id("com.google.firebase.firebase-perf") version "2.0.2" apply false
id("com.google.firebase.crashlytics") version "3.0.6" apply false
}
include(":app")

File diff suppressed because it is too large Load diff

View file

@ -1,28 +1,77 @@
## v5.3.1
## v4.9.6
October 12, 2026
June 14, 2025
### 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.
- [Android] Fix missing stream quality options
---
## v5.3.0
## v4.9.5
October 12, 2026
June 13, 2025
### Bug Fixes
- Fix stream latency no longer updating
---
## v4.9.4
June 6, 2025
### Bug Fixes
- Fix video stats blocking when using custom overlay
---
## v4.9.3
May 26, 2025
### Bug Fixes
- Fix missing 7TV emotes on some channels
- Fix sleep timer menu border styling
---
## v4.9.2
April 7, 2025
### Bug Fixes
- Fix large cache size (by @Artiu on GitHub)
---
## v4.9.1
April 4, 2025
### Bug Fixes
- [Android] Fix overlay latency and stream qualities, persist last chosen stream quality (by @Artiu on GitHub)
---
## v4.9.0
December 30, 2024
### New Features
- Added a Follow / Unfollow button under the player (between video and chat) for live and offline channels.
---
## v5.2.9
October 7, 2026
- [Android] Added new "Use enhanced rendering" option in video settings
### 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 follow/unfollow failing with an integrity check error while logged in by confirming via a short Twitch WebView when needed.
- Fixed incorrect badge order and spacing for FFZ bots
- Fixed missing emotes on channels that don't have 7TV enabled
#### See all release notes at 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>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>12.0</string>
</dict>
</plist>

View file

@ -1,49 +1,48 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
CFPropertyList (3.0.7)
base64
nkf
rexml
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1260.0)
aws-sdk-core (3.252.0)
aws-eventstream (1.3.2)
aws-partitions (1.1082.0)
aws-sdk-core (3.222.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.129.0)
aws-sdk-core (~> 3, >= 3.248.0)
aws-sdk-kms (1.99.0)
aws-sdk-core (~> 3, >= 3.216.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.225.1)
aws-sdk-core (~> 3, >= 3.248.0)
aws-sdk-s3 (1.183.0)
aws-sdk-core (~> 3, >= 3.216.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-sigv4 (1.11.0)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.3.0)
benchmark (0.5.0)
bigdecimal (4.1.2)
base64 (0.2.0)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.5)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.112.0)
faraday (1.10.5)
excon (0.109.0)
faraday (1.10.4)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
@ -55,36 +54,32 @@ GEM
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.8)
faraday-cookie_jar (0.0.7)
faraday (>= 0.8.0)
http-cookie (>= 1.0.0)
http-cookie (~> 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.2.0)
faraday-multipart (1.1.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.4)
faraday-retry (1.0.3)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.1)
fastlane (2.234.0)
CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1)
fastimage (2.4.0)
fastlane (2.227.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.197)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2)
benchmark (>= 0.1.0)
bundler (>= 1.17.3, < 5.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
@ -92,24 +87,20 @@ GEM
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.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)
google-apis-androidpublisher_v3 (~> 0.3)
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)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3)
naturally (~> 2.2)
nkf (~> 0.2)
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
@ -120,44 +111,43 @@ GEM
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty (~> 0.4.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)
google-apis-androidpublisher_v3 (0.102.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.3)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
google-apis-iamcredentials_v1 (0.27.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.63.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.9.0)
rexml
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.29.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.6.1)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.1.1)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.6.0)
google-cloud-storage (1.61.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.1)
google-cloud-storage (1.45.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.29.0)
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.11.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.1)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
@ -168,41 +158,42 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.19.9)
jwt (2.10.3)
json (2.7.6)
jwt (2.10.1)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.21.1)
multi_json (1.15.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
nkf (0.3.0)
optparse (0.8.1)
naturally (2.2.1)
nkf (0.2.0)
optparse (0.6.0)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
public_suffix (7.0.5)
rake (13.4.2)
public_suffix (5.1.1)
rake (13.2.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.8.0)
rexml (3.4.4)
retriable (3.1.2)
rexml (3.4.1)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.22.0)
signet (0.18.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
@ -212,6 +203,7 @@ GEM
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unf (0.2.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.27.0)
@ -233,7 +225,6 @@ PLATFORMS
universal-darwin-24
x86_64-darwin-20
x86_64-darwin-21
x86_64-linux
DEPENDENCIES
fastlane

View file

@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
platform :ios, '15.0'
# platform :ios, '12.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View file

@ -1,28 +1,306 @@
PODS:
- advanced_in_app_review (0.0.1):
- Flutter
- device_info_plus (0.0.1):
- Flutter
- Firebase/Analytics (11.10.0):
- Firebase/Core
- Firebase/Core (11.10.0):
- Firebase/CoreOnly
- FirebaseAnalytics (~> 11.10.0)
- Firebase/CoreOnly (11.10.0):
- FirebaseCore (~> 11.10.0)
- Firebase/Crashlytics (11.10.0):
- Firebase/CoreOnly
- FirebaseCrashlytics (~> 11.10.0)
- Firebase/Performance (11.10.0):
- Firebase/CoreOnly
- FirebasePerformance (~> 11.10.0)
- firebase_analytics (11.4.6):
- Firebase/Analytics (= 11.10.0)
- firebase_core
- Flutter
- firebase_core (3.13.1):
- Firebase/CoreOnly (= 11.10.0)
- Flutter
- firebase_crashlytics (4.3.6):
- Firebase/Crashlytics (= 11.10.0)
- firebase_core
- Flutter
- firebase_performance (0.10.1-6):
- Firebase/Performance (= 11.10.0)
- firebase_core
- Flutter
- FirebaseABTesting (11.10.0):
- FirebaseCore (~> 11.10.0)
- FirebaseAnalytics (11.10.0):
- FirebaseAnalytics/AdIdSupport (= 11.10.0)
- FirebaseCore (~> 11.10.0)
- FirebaseInstallations (~> 11.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0)
- FirebaseAnalytics/AdIdSupport (11.10.0):
- FirebaseCore (~> 11.10.0)
- FirebaseInstallations (~> 11.0)
- GoogleAppMeasurement (= 11.10.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0)
- FirebaseCore (11.10.0):
- FirebaseCoreInternal (~> 11.10.0)
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/Logger (~> 8.0)
- FirebaseCoreExtension (11.10.0):
- FirebaseCore (~> 11.10.0)
- FirebaseCoreInternal (11.10.0):
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- FirebaseCrashlytics (11.10.0):
- FirebaseCore (~> 11.10.0)
- FirebaseInstallations (~> 11.0)
- FirebaseRemoteConfigInterop (~> 11.0)
- FirebaseSessions (~> 11.0)
- GoogleDataTransport (~> 10.0)
- GoogleUtilities/Environment (~> 8.0)
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- FirebaseInstallations (11.10.0):
- FirebaseCore (~> 11.10.0)
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- PromisesObjC (~> 2.4)
- FirebasePerformance (11.10.0):
- FirebaseCore (~> 11.10.0)
- FirebaseInstallations (~> 11.0)
- FirebaseRemoteConfig (~> 11.0)
- FirebaseSessions (~> 11.0)
- GoogleDataTransport (~> 10.0)
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- nanopb (~> 3.30910.0)
- FirebaseRemoteConfig (11.10.0):
- FirebaseABTesting (~> 11.0)
- FirebaseCore (~> 11.10.0)
- FirebaseInstallations (~> 11.0)
- FirebaseRemoteConfigInterop (~> 11.0)
- FirebaseSharedSwift (~> 11.0)
- GoogleUtilities/Environment (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- FirebaseRemoteConfigInterop (11.13.0)
- FirebaseSessions (11.10.0):
- FirebaseCore (~> 11.10.0)
- FirebaseCoreExtension (~> 11.10.0)
- FirebaseInstallations (~> 11.0)
- GoogleDataTransport (~> 10.0)
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- nanopb (~> 3.30910.0)
- PromisesSwift (~> 2.1)
- FirebaseSharedSwift (11.13.0)
- Flutter (1.0.0)
- flutter_native_splash (2.4.3):
- Flutter
- flutter_secure_storage (6.0.0):
- Flutter
- GoogleAppMeasurement (11.10.0):
- GoogleAppMeasurement/AdIdSupport (= 11.10.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/AdIdSupport (11.10.0):
- GoogleAppMeasurement/WithoutAdIdSupport (= 11.10.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/WithoutAdIdSupport (11.10.0):
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0)
- GoogleDataTransport (10.1.0):
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- GoogleUtilities/AppDelegateSwizzler (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
- GoogleUtilities/Network
- GoogleUtilities/Privacy
- GoogleUtilities/Environment (8.1.0):
- GoogleUtilities/Privacy
- GoogleUtilities/Logger (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/MethodSwizzler (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilities/Network (8.1.0):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Privacy
- GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (8.1.0)":
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (8.1.0)
- GoogleUtilities/Reachability (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilities/UserDefaults (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- in_app_review (2.0.0):
- Flutter
- nanopb (3.30910.0):
- nanopb/decode (= 3.30910.0)
- nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- PromisesObjC (2.4.0)
- PromisesSwift (2.4.0):
- PromisesObjC (= 2.4.0)
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
- wakelock_plus (0.0.1):
- Flutter
- webview_flutter_wkwebview (0.0.1):
- Flutter
- FlutterMacOS
DEPENDENCIES:
- advanced_in_app_review (from `.symlinks/plugins/advanced_in_app_review/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`)
- firebase_performance (from `.symlinks/plugins/firebase_performance/ios`)
- Flutter (from `Flutter`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
SPEC REPOS:
trunk:
- Firebase
- FirebaseABTesting
- FirebaseAnalytics
- FirebaseCore
- FirebaseCoreExtension
- FirebaseCoreInternal
- FirebaseCrashlytics
- FirebaseInstallations
- FirebasePerformance
- FirebaseRemoteConfig
- FirebaseRemoteConfigInterop
- FirebaseSessions
- FirebaseSharedSwift
- GoogleAppMeasurement
- GoogleDataTransport
- GoogleUtilities
- nanopb
- PromisesObjC
- PromisesSwift
EXTERNAL SOURCES:
advanced_in_app_review:
:path: ".symlinks/plugins/advanced_in_app_review/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
firebase_analytics:
:path: ".symlinks/plugins/firebase_analytics/ios"
firebase_core:
:path: ".symlinks/plugins/firebase_core/ios"
firebase_crashlytics:
:path: ".symlinks/plugins/firebase_crashlytics/ios"
firebase_performance:
:path: ".symlinks/plugins/firebase_performance/ios"
Flutter:
:path: Flutter
flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
in_app_review:
:path: ".symlinks/plugins/in_app_review/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
SPEC CHECKSUMS:
advanced_in_app_review: daebe4c801525b756fe589de60fd327cb12f965c
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
advanced_in_app_review: fe8fd50b8670cb3dbe67e4c26ca643f7aea8dbb8
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
Firebase: 1fe1c0a7d9aaea32efe01fbea5f0ebd8d70e53a2
firebase_analytics: 943aa4699bf6afe056a96991ba8ee34776323d4c
firebase_core: 3c2f323cae65c97a636a05a23b17730ef93df2cf
firebase_crashlytics: 6f5f71ba7ec7b6a0412c4ed076272643c434f69f
firebase_performance: da79b34e2753057f7028c5e41ac5d8b3c5398eb2
FirebaseABTesting: dfc10eb6cc08fe3b391ac9e5aa40396d43ea6675
FirebaseAnalytics: 4e42333f02cf78ed93703a5c36f36dd518aebdef
FirebaseCore: 8344daef5e2661eb004b177488d6f9f0f24251b7
FirebaseCoreExtension: 6f357679327f3614e995dc7cf3f2d600bdc774ac
FirebaseCoreInternal: ef4505d2afb1d0ebbc33162cb3795382904b5679
FirebaseCrashlytics: 84b073c997235740e6a951b7ee49608932877e5c
FirebaseInstallations: 9980995bdd06ec8081dfb6ab364162bdd64245c3
FirebasePerformance: f0bd14be05aaa1136cbb1c9aaaf87d213d0e6fbf
FirebaseRemoteConfig: 10695bc0ce3b103e3706a5578c43f2a9f69d5aaa
FirebaseRemoteConfigInterop: 7915cec47731a806cda541f90898ad0fab8f9f86
FirebaseSessions: 9b3b30947b97a15370e0902ee7a90f50ef60ead6
FirebaseSharedSwift: aca73668bc95e8efccb618e0167eab05d19d3a75
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
GoogleAppMeasurement: 36684bfb3ee034e2b42b4321eb19da3a1b81e65d
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
in_app_review: a31b5257259646ea78e0e35fc914979b0031d011
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e
webview_flutter_wkwebview: a4af96a051138e28e29f60101d094683b9f82188
PODFILE CHECKSUM: 57c8aed26fba39d3ec9424816221f294a07c58eb
PODFILE CHECKSUM: c4c93c5f6502fe2754f48404d3594bf779584011
COCOAPODS: 1.16.2

View file

@ -11,13 +11,10 @@
3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E751BC99755AAC391DC0B29F /* Pods_Runner.framework */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
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 */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
BE8095795EAFF58C08A43B48 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A68609F67C30CFEF267CD4D3 /* GoogleService-Info.plist */; };
D4F5A60712340001ABCD1234 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
@ -40,7 +37,6 @@
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>"; };
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>"; };
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>"; };
@ -52,9 +48,7 @@
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A68609F67C30CFEF267CD4D3 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = "<group>"; };
CFF3F6A0DA5A835B219C2D3C /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
E751BC99755AAC391DC0B29F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -62,7 +56,6 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -73,7 +66,6 @@
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@ -112,9 +104,7 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */,
);
path = Runner;
sourceTree = "<group>";
@ -141,9 +131,6 @@
/* Begin PBXNativeTarget section */
97C146ED1CF9000F007C117D /* Runner */ = {
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
@ -170,9 +157,6 @@
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1510;
@ -212,7 +196,6 @@
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
BE8095795EAFF58C08A43B48 /* GoogleService-Info.plist in Resources */,
D4F5A60712340001ABCD1234 /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -259,7 +242,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
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 */ = {
isa = PBXShellScriptBuildPhase;
@ -317,7 +300,6 @@
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
F1C00CE01A2B3C4D00E5F6A8 /* CookieExtractorPlugin.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -385,7 +367,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@ -467,7 +449,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@ -516,7 +498,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@ -604,18 +586,6 @@
defaultConfigurationName = Release;
};
/* 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 */;
}

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
parallelizeBuildables = "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>
<BuildActionEntry
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
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
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/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<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>
<false/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
</dict>
</plist>

View file

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyTracking</key>
<false/>
</dict>
</plist>

View file

@ -17,9 +17,7 @@ default_platform(:ios)
platform :ios do
desc 'Push a new release build to the App Store'
lane :release do |options|
dry_run = options[:dry_run] || false
lane :release do
# Setup for CI. Uses a keychain to prevent the CI from hanging due to password prompts.
setup_ci
@ -31,9 +29,8 @@ platform :ios do
is_key_content_base64: true
)
# Manage code signing (with match). readonly on a dry run so we only verify the
# existing cert/profile resolve and never regenerate or mutate the certificates repo.
sync_code_signing(readonly: dry_run)
# Manage code signing (with match).
sync_code_signing
# Automatic signing is used during development, but fastlane needs to use manual signing.
update_code_signing_settings(
@ -48,11 +45,6 @@ platform :ios do
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_to_app_store(
submit_for_review: true,

View file

@ -1 +1 @@
2026 Tommy Chow
2025 Tommy Chow

View file

@ -1 +1 @@
Frosty
Frosty for Twitch

View file

@ -1 +1 @@
https://frostyapp.io/privacy
https://www.privacypolicies.com/live/095b4264-31b1-4280-837e-5843abde8b16

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

@ -1,269 +0,0 @@
import 'package:dio/dio.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
/// Common type aliases to reduce repetition
typedef JsonMap = Map<String, dynamic>;
typedef JsonList = List<dynamic>;
typedef QueryParams = Map<String, dynamic>;
typedef ApiHeaders = Map<String, String>;
/// Custom API exceptions for better error handling
class ApiException implements Exception {
final String message;
final int? statusCode;
final DioExceptionType? type;
const ApiException(this.message, [this.statusCode, this.type]);
@override
String toString() => 'ApiException: $message';
}
class NetworkException extends ApiException {
const NetworkException(String message)
: super(message, null, DioExceptionType.connectionError);
}
class TimeoutException extends ApiException {
const TimeoutException(String message)
: super(message, null, DioExceptionType.connectionTimeout);
}
class ServerException extends ApiException {
const ServerException(String message, int statusCode)
: super(message, statusCode, DioExceptionType.badResponse);
}
class NotFoundException extends ApiException {
const NotFoundException(String message)
: super(message, 404, DioExceptionType.badResponse);
}
class UnauthorizedException extends ApiException {
const UnauthorizedException(String message)
: super(message, 401, DioExceptionType.badResponse);
}
/// Twitch GQL rejected the request for a missing/invalid Client-Integrity token.
class IntegrityCheckException extends ApiException {
const IntegrityCheckException([
String message = 'Twitch integrity check failed',
]) : super(message);
}
/// Base API client to eliminate code duplication across API services
abstract class BaseApiClient {
final Dio _dio;
final String baseUrl;
const BaseApiClient(this._dio, this.baseUrl);
/// Builds the full URL, handling both relative endpoints and full URLs
String _buildUrl(String endpoint) {
// If endpoint is already a full URL (starts with http:// or https://), use it as-is
if (endpoint.startsWith('http://') || endpoint.startsWith('https://')) {
return endpoint;
}
// Otherwise, concatenate with base URL
return '$baseUrl$endpoint';
}
/// Generic GET request with common error handling
Future<T> get<T>(
String endpoint, {
QueryParams? queryParameters,
ApiHeaders? headers,
CancelToken? cancelToken,
}) async {
try {
final url = _buildUrl(endpoint);
final response = await _dio.get(
url,
queryParameters: queryParameters,
options: Options(headers: headers),
cancelToken: cancelToken,
);
return response.data as T;
} on DioException catch (e) {
throw _handleError(e);
}
}
/// Generic POST request with common error handling
Future<T> post<T>(
String endpoint, {
dynamic data,
QueryParams? queryParameters,
ApiHeaders? headers,
CancelToken? cancelToken,
}) async {
try {
final url = _buildUrl(endpoint);
final response = await _dio.post(
url,
data: data,
queryParameters: queryParameters,
options: Options(headers: headers),
cancelToken: cancelToken,
);
return response.data as T;
} on DioException catch (e) {
throw _handleError(e);
}
}
/// Generic PUT request with common error handling
Future<T> put<T>(
String endpoint, {
dynamic data,
QueryParams? queryParameters,
ApiHeaders? headers,
CancelToken? cancelToken,
}) async {
try {
final url = _buildUrl(endpoint);
final response = await _dio.put(
url,
data: data,
queryParameters: queryParameters,
options: Options(headers: headers),
cancelToken: cancelToken,
);
return response.data as T;
} on DioException catch (e) {
throw _handleError(e);
}
}
/// Generic DELETE request with common error handling
Future<T> delete<T>(
String endpoint, {
QueryParams? queryParameters,
ApiHeaders? headers,
CancelToken? cancelToken,
}) async {
try {
final url = _buildUrl(endpoint);
final response = await _dio.delete(
url,
queryParameters: queryParameters,
options: Options(headers: headers),
cancelToken: cancelToken,
);
return response.data as T;
} on DioException catch (e) {
throw _handleError(e);
}
}
/// Check if response indicates success (useful for status-only operations)
bool isSuccessResponse(Response response) {
return response.statusCode != null &&
response.statusCode! >= 200 &&
response.statusCode! < 300;
}
/// Converts DioException to appropriate ApiException subtype
ApiException _handleError(DioException error) {
final exception = switch (error.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.sendTimeout ||
DioExceptionType.receiveTimeout ||
DioExceptionType.transformTimeout =>
TimeoutException(_getTimeoutMessage(error.type)),
DioExceptionType.connectionError => NetworkException(
'No internet connection. Please check your network.',
),
DioExceptionType.badResponse => _handleHttpError(error),
DioExceptionType.cancel => ApiException('Request was cancelled'),
DioExceptionType.badCertificate =>
ApiException('Security certificate error'),
DioExceptionType.unknown =>
ApiException(error.message ?? 'An unexpected error occurred'),
};
// Record server errors and unexpected failures to Crashlytics.
// Skip timeouts, network errors (normal offline conditions), 401s, and cancellations.
if (exception is ServerException ||
error.type == DioExceptionType.unknown ||
error.type == DioExceptionType.badCertificate) {
try {
FirebaseCrashlytics.instance.recordError(
exception,
error.stackTrace,
reason: '${error.requestOptions.method} ${error.requestOptions.uri}',
);
} catch (_) {
// Firebase may not be initialized (e.g., in tests).
}
}
return exception;
}
/// Gets specific timeout message based on timeout type
String _getTimeoutMessage(DioExceptionType type) {
switch (type) {
case DioExceptionType.connectionTimeout:
return 'Connection timeout. Please try again.';
case DioExceptionType.sendTimeout:
return 'Upload timeout. Please try again.';
case DioExceptionType.receiveTimeout:
return 'Server response timeout. Please try again.';
case DioExceptionType.transformTimeout:
return 'Response processing timeout. Please try again.';
default:
return 'Request timeout. Please try again.';
}
}
/// Handles HTTP status code errors with specific exception types
ApiException _handleHttpError(DioException error) {
final statusCode = error.response?.statusCode;
final responseData = error.response?.data;
// Extract error message from server response
String? serverMessage;
if (responseData is JsonMap) {
serverMessage =
responseData['message'] as String? ??
responseData['error'] as String? ??
responseData['error_description'] as String?;
}
switch (statusCode) {
case 400:
return ApiException(serverMessage ?? 'Invalid request data');
case 401:
return UnauthorizedException('Please log in to continue');
case 403:
return ApiException('Access denied. Insufficient permissions');
case 404:
return NotFoundException(serverMessage ?? 'Resource not found');
case 409:
return ApiException('Conflict with current state. Please refresh');
case 422:
return ApiException(serverMessage ?? 'Invalid data provided');
case 429:
return ApiException(
'Rate limit exceeded. Please wait before trying again',
);
case 500:
return ServerException('Server error. Please try again later', 500);
case 502:
return ServerException('Service temporarily unavailable', 502);
case 503:
return ServerException(
'Service maintenance. Please try again later',
503,
);
case 504:
return ServerException('Server timeout. Please try again', 504);
default:
return ServerException(
serverMessage ?? 'HTTP Error $statusCode occurred',
statusCode ?? 500,
);
}
}
}

View file

@ -1,55 +1,75 @@
import 'package:dio/dio.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'dart:convert';
import 'package:frosty/models/badges.dart';
import 'package:frosty/models/emotes.dart';
import 'package:http/http.dart';
/// The BTTV service for making API calls.
class BTTVApi extends BaseApiClient {
BTTVApi(Dio dio) : super(dio, 'https://api.betterttv.net/3/cached');
class BTTVApi {
final Client _client;
const BTTVApi(this._client);
/// Returns a map of global BTTV emotes to their URL.
Future<List<Emote>> getEmotesGlobal() async {
final data = await get<JsonList>('/emotes/global');
final url = Uri.parse('https://api.betterttv.net/3/cached/emotes/global');
final emotes = data.map((emote) => EmoteBTTV.fromJson(emote)).toList();
final response = await _client.get(url);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body) as List;
final emotes = decoded.map((emote) => EmoteBTTV.fromJson(emote)).toList();
return emotes
.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvGlobal))
.toList();
} else {
return Future.error('Failed to get BTTV global emotes');
}
}
/// Returns a map of a channel's BTTV emotes to their URL.
Future<List<Emote>> getEmotesChannel({required String id}) async {
final data = await get<JsonMap>('/users/twitch/$id');
final url =
Uri.parse('https://api.betterttv.net/3/cached/users/twitch/$id');
final result = EmoteBTTVChannel.fromJson(data);
final response = await _client.get(url);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
final result = EmoteBTTVChannel.fromJson(decoded);
final emoteToUrl = <Emote>[];
emoteToUrl.addAll(
result.channelEmotes.map(
(emote) => Emote.fromBTTV(emote, EmoteType.bttvChannel),
),
result.channelEmotes
.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvChannel)),
);
emoteToUrl.addAll(
result.sharedEmotes.map(
(emote) => Emote.fromBTTV(emote, EmoteType.bttvShared),
),
result.sharedEmotes
.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvShared)),
);
return emoteToUrl;
} else {
return Future.error('Failed to get BTTV channel emotes');
}
}
/// Returns a map of username to their BTTV badge.
Future<Map<String, ChatBadge>> getBadges() async {
final data = await get<JsonList>('/badges');
final url = Uri.parse('https://api.betterttv.net/3/cached/badges');
final badgeObjects = data
.map((badge) => BadgeInfoBTTV.fromJson(badge))
.toList();
final response = await _client.get(url);
if (response.statusCode == 200) {
final badges = jsonDecode(response.body) as List;
final badgeObjects =
badges.map((badge) => BadgeInfoBTTV.fromJson(badge)).toList();
return {
for (final badge in badgeObjects)
badge.providerId: ChatBadge.fromBTTV(badge),
};
} else {
return Future.error('Failed to get BTTV badges');
}
}
}

View file

@ -1,71 +0,0 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:frosty/apis/firebase_performance_interceptor.dart';
/// Centralized Dio client configuration with interceptors and error handling
class DioClient {
static Dio createClient({List<Interceptor>? additionalInterceptors}) {
final dio = Dio();
// Optimized configuration based on 2024-2025 best practices
dio.options = BaseOptions(
connectTimeout: const Duration(
seconds: 8,
), // Balanced for various networks
receiveTimeout: const Duration(
seconds: 15,
), // Allow for larger API responses
sendTimeout: const Duration(seconds: 10), // Reasonable for uploads
headers: {
'User-Agent': 'Frosty (Flutter Twitch Client)',
'Accept': 'application/json',
'Content-Type': 'application/json',
'Connection': 'keep-alive', // Enable connection reuse
},
followRedirects: true,
maxRedirects: 3, // Reduced for better performance
persistentConnection: true, // Enable connection pooling
);
// Simple logging for debug builds
if (kDebugMode) {
dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
debugPrint('→ ${options.method} ${options.uri}');
handler.next(options);
},
onResponse: (response, handler) {
final statusCode = response.statusCode ?? 0;
final emoji = statusCode >= 200 && statusCode < 300 ? '✅' : '❌';
debugPrint(
'$emoji ${response.statusCode} ${response.requestOptions.method} ${response.requestOptions.uri}',
);
handler.next(response);
},
onError: (error, handler) {
debugPrint(
'❌ ${error.type.name} ${error.requestOptions.method} ${error.requestOptions.uri}',
);
if (error.response != null) {
debugPrint(' Status: ${error.response!.statusCode}');
}
handler.next(error);
},
),
);
}
// Trace HTTP requests as Firebase Performance metrics
dio.interceptors.add(FirebasePerformanceInterceptor());
// Add any additional interceptors provided
if (additionalInterceptors != null) {
for (final interceptor in additionalInterceptors) {
dio.interceptors.add(interceptor);
}
}
return dio;
}
}

View file

@ -1,36 +1,49 @@
import 'package:dio/dio.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'dart:convert';
import 'package:frosty/models/badges.dart';
import 'package:frosty/models/emotes.dart';
import 'package:http/http.dart';
/// The FFZ service for making API calls.
class FFZApi extends BaseApiClient {
FFZApi(Dio dio) : super(dio, 'https://api.frankerfacez.com/v1');
class FFZApi {
final Client _client;
const FFZApi(this._client);
/// Returns a map of global FFZ emotes to their URL.
Future<List<Emote>> getEmotesGlobal() async {
final data = await get<JsonMap>('/set/global');
final url = Uri.parse('https://api.frankerfacez.com/v1/set/global');
final defaultSets = data['default_sets'] as JsonList;
final response = await _client.get(url);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
final defaultSets = decoded['default_sets'] as List;
final emotes = <EmoteFFZ>[];
for (final setId in defaultSets) {
final emoticons = data['sets'][setId.toString()]['emoticons'] as JsonList;
final emoticons =
decoded['sets'][setId.toString()]['emoticons'] as List;
emotes.addAll(emoticons.map((emote) => EmoteFFZ.fromJson(emote)));
}
return emotes
.map((emote) => Emote.fromFFZ(emote, EmoteType.ffzGlobal))
.toList();
} else {
return Future.error('Failed to get FFZ global emotes');
}
}
/// Returns a channel's FFZ room info including custom badges and emote used.
Future<(RoomFFZ, List<Emote>)> getRoomInfo({required String id}) async {
final data = await get<JsonMap>('/room/id/$id');
final url = Uri.parse('https://api.frankerfacez.com/v1/room/id/$id');
final roomInfo = RoomFFZ.fromJson(data['room']);
final response = await _client.get(url);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
final roomInfo = RoomFFZ.fromJson(decoded['room']);
final emoticons =
data['sets'][roomInfo.set.toString()]['emoticons'] as JsonList;
decoded['sets'][roomInfo.set.toString()]['emoticons'] as List;
final emotes = emoticons.map((emote) => EmoteFFZ.fromJson(emote));
@ -38,22 +51,27 @@ class FFZApi extends BaseApiClient {
roomInfo,
emotes
.map((emote) => Emote.fromFFZ(emote, EmoteType.ffzChannel))
.toList(),
.toList()
);
} else {
return Future.error('Failed to get FFZ room info');
}
}
/// 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 {
final data = await get<JsonMap>('/badges/ids');
final url = Uri.parse('https://api.frankerfacez.com/v1/badges/ids');
final badges = data['badges'] as JsonList;
final badgeObjects = badges
.map((badge) => BadgeInfoFFZ.fromJson(badge))
.toList();
final response = await _client.get(url);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
final badges = decoded['badges'] as List;
final badgeObjects =
badges.map((badge) => BadgeInfoFFZ.fromJson(badge)).toList();
final result = <String, List<ChatBadge>>{};
for (final badge in badgeObjects.reversed) {
for (final userId in data['users'][badge.id.toString()]) {
for (final userId in decoded['users'][badge.id.toString()]) {
final entry = result[userId.toString()];
final normalBadge = ChatBadge.fromFFZ(badge);
if (entry == null) {
@ -65,5 +83,8 @@ class FFZApi extends BaseApiClient {
}
return result;
} else {
return Future.error('Failed to get FFZ badges');
}
}
}

View file

@ -1,46 +0,0 @@
import 'package:dio/dio.dart';
import 'package:firebase_performance/firebase_performance.dart';
/// Dio interceptor that traces HTTP requests as Firebase Performance metrics.
class FirebasePerformanceInterceptor extends Interceptor {
static const _metricKey = 'firebase_performance_metric';
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final metric = FirebasePerformance.instance.newHttpMetric(
options.uri.toString(),
_getHttpMethod(options.method),
);
metric.start();
options.extra[_metricKey] = metric;
handler.next(options);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
_stopMetric(response.requestOptions, response.statusCode);
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
_stopMetric(err.requestOptions, err.response?.statusCode);
handler.next(err);
}
void _stopMetric(RequestOptions options, int? statusCode) {
final metric = options.extra.remove(_metricKey) as HttpMetric?;
if (metric == null) return;
if (statusCode != null) metric.httpResponseCode = statusCode;
metric.stop();
}
static HttpMethod _getHttpMethod(String method) => switch (method.toUpperCase()) {
'GET' => HttpMethod.Get,
'POST' => HttpMethod.Post,
'PUT' => HttpMethod.Put,
'DELETE' => HttpMethod.Delete,
'PATCH' => HttpMethod.Patch,
_ => HttpMethod.Get,
};
}

View file

@ -1,39 +1,52 @@
import 'package:dio/dio.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'dart:convert';
import 'package:frosty/models/emotes.dart';
import 'package:http/http.dart';
/// The 7TV service for making API calls.
class SevenTVApi extends BaseApiClient {
SevenTVApi(Dio dio) : super(dio, 'https://7tv.io/v3');
class SevenTVApi {
final Client _client;
const SevenTVApi(this._client);
/// Returns a map of global 7TV emotes to their URL.
Future<List<Emote>> getEmotesGlobal() async {
final data = await get<JsonMap>('/emote-sets/global');
final url = Uri.parse('https://7tv.io/v3/emote-sets/global');
final decoded = data['emotes'] as JsonList;
final response = await _client.get(url);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body)['emotes'] as List;
final emotes = decoded.map((emote) => Emote7TV.fromJson(emote));
return emotes
.map((emote) => Emote.from7TV(emote, EmoteType.sevenTVGlobal))
.toList();
} else {
return Future.error('Failed to get 7TV global emotes');
}
}
/// Returns a tuple containing the emote set ID and a map of a channel's 7TV
/// emotes to their URL.
Future<(String, List<Emote>)> getEmotesChannel({required String id}) async {
final data = await get<JsonMap>('/users/twitch/$id');
final url = Uri.parse('https://7tv.io/v3/users/twitch/$id');
final emoteSetId = data['emote_set']['id'] as String;
final emotes = (data['emote_set']['emotes'] as JsonList).map(
(emote) => Emote7TV.fromJson(emote),
);
final response = await _client.get(url);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
final emoteSetId = decoded['emote_set']['id'] as String;
final emotes = (decoded['emote_set']['emotes'] as List)
.map((emote) => Emote7TV.fromJson(emote));
return (
emoteSetId,
emotes
.map((emote) => Emote.from7TV(emote, EmoteType.sevenTVChannel))
.where((emote) => emote.url.isNotEmpty)
.toList(),
.toList()
);
} else {
return Future.error('Failed to get 7TV channel emotes');
}
}
}

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';

File diff suppressed because it is too large Load diff

View file

@ -1,31 +0,0 @@
import 'package:dio/dio.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
/// Dio interceptor that automatically adds Twitch authentication headers
/// to requests targeting Twitch API endpoints.
class TwitchAuthInterceptor extends Interceptor {
final AuthStore _authStore;
const TwitchAuthInterceptor(this._authStore);
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
// Check if this is a Twitch API request that needs authentication
if (_shouldAddTwitchHeaders(options.uri)) {
// Add Twitch auth headers automatically
final twitchHeaders = _authStore.headersTwitch;
options.headers.addAll(twitchHeaders);
}
handler.next(options);
}
/// Determines if the request URL is for a Twitch API endpoint that requires authentication
bool _shouldAddTwitchHeaders(Uri uri) {
final url = uri.toString();
// Add headers for Twitch Helix API and OAuth endpoints
return url.startsWith('https://api.twitch.tv/helix') ||
url.startsWith('https://id.twitch.tv/oauth2');
}
}

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

@ -1,143 +0,0 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:frosty/main.dart';
import 'package:frosty/screens/onboarding/login_webview.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/utils/twitch_oauth_scopes.dart';
import 'package:frosty/widgets/frosty_dialog.dart';
import 'package:provider/provider.dart';
/// Dio interceptor that catches 401 Unauthorized errors and shows a login dialog
class UnauthorizedInterceptor extends Interceptor {
final AuthStore _authStore;
UnauthorizedInterceptor(this._authStore);
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
// Check if this is a 401 Unauthorized error on a Twitch Helix/OAuth request
if (err.response?.statusCode == 401 &&
_isTwitchHelixOrOAuth(err.requestOptions.uri)) {
// For token validation requests, let the error propagate so validateToken can handle it
if (err.requestOptions.uri.path.endsWith('/validate')) {
handler.next(err);
return;
}
final path = err.requestOptions.uri.path;
// Background moderated-channels fetches must never nag — especially for
// core-only tokens after an app upgrade.
if (isTwitchModeratedChannelsPath(path)) {
handler.reject(err);
return;
}
// Moderator actions without scopes → opt-in Enable flow, not a generic
// "missing permissions" re-login for core scopes.
if (isTwitchModeratorActionPath(path) &&
!_authStore.hasModeratorScopes) {
_showAuthDialog(
title: 'Enable moderator tools',
message: enableModeratorToolsDialogMessage(),
primaryLabel: 'Enable',
onPrimary: () => openEnableModeratorTools(_authStore),
);
handler.reject(err);
return;
}
// Core-only messaging: if core scopes are present, prefer "Session expired"
// over "Missing permissions" (401 ≠ always missing scope).
final isLoggedIn = _authStore.isLoggedIn;
final missingCore = missingTwitchScopes(
granted: _authStore.grantedScopes,
);
_showAuthDialog(
title: isLoggedIn && missingCore.isNotEmpty
? 'Missing permissions'
: 'Session expired',
message: unauthorizedDialogMessage(
isLoggedIn: isLoggedIn,
grantedScopes: _authStore.grantedScopes,
),
primaryLabel: 'Log in',
onPrimary: () {
final context = navigatorKey.currentContext;
if (context == null) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => Provider<AuthStore>.value(
value: _authStore,
child: const LoginWebView(),
),
),
);
},
);
handler.reject(err);
return;
}
// For non-401 errors, continue with normal error handling
handler.next(err);
}
void _showAuthDialog({
required String title,
required String message,
required String primaryLabel,
required VoidCallback onPrimary,
}) {
final context = navigatorKey.currentContext;
if (context == null) return;
if (_isDialogShowing) return;
_isDialogShowing = true;
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return FrostyDialog(
title: title,
message: message,
actions: [
TextButton(
onPressed: () {
_isDialogShowing = false;
Navigator.of(dialogContext).pop();
},
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
_isDialogShowing = false;
Navigator.of(dialogContext).pop();
onPrimary();
},
child: Text(primaryLabel),
),
],
);
},
).then((_) {
_isDialogShowing = false;
});
}
static bool _isDialogShowing = false;
bool _isTwitchHelixOrOAuth(Uri uri) {
final url = uri.toString();
return url.startsWith('https://api.twitch.tv/helix') ||
url.startsWith('https://id.twitch.tv/oauth2');
}
}
/// Helix path for listing channels the user moderates.
bool isTwitchModeratedChannelsPath(String path) =>
path.contains('/moderation/channels');
/// Helix paths for delete / ban / timeout / unban.
bool isTwitchModeratorActionPath(String path) =>
path.contains('/moderation/chat') || path.contains('/moderation/bans');

View file

@ -1,20 +1,6 @@
import 'package:flutter/material.dart';
/// Twitch API 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.
const secret = String.fromEnvironment('SECRET');
@ -33,9 +19,6 @@ const zeroWidthEmotes = [
/// Regex for matching strings that contain lower or upper case English characters.
final regexEnglish = RegExp(r'[a-zA-Z]');
/// Regex for matching strings that contain only numeric characters.
final regexNumbersOnly = RegExp(r'^\d+$');
/// Regex for matching URLs and file names in text.
final regexLink = RegExp(
r'(?<![A-Za-z0-9_.-])' // left boundary
@ -54,49 +37,8 @@ final regexLink = RegExp(
caseSensitive: false,
);
/// Standard corner radius for card thumbnails and box art.
const kCardBorderRadius = BorderRadius.all(Radius.circular(8));
/// The default badge width and height.
const defaultBadgeSize = 18.0;
/// The default emote width and height when none are provided.
const defaultEmoteSize = 28.0;
/// Available named chat colors for Twitch users.
const chatColorNames = [
'blue',
'blue_violet',
'cadet_blue',
'chocolate',
'coral',
'dodger_blue',
'firebrick',
'golden_rod',
'green',
'hot_pink',
'orange_red',
'red',
'sea_green',
'spring_green',
'yellow_green',
];
/// Color values for the named chat colors.
const Map<String, Color> chatColorValues = {
'blue': Color(0xFF0000FF),
'blue_violet': Color(0xFF8A2BE2),
'cadet_blue': Color(0xFF5F9EA0),
'chocolate': Color(0xFFD2691E),
'coral': Color(0xFFFF7F50),
'dodger_blue': Color(0xFF1E90FF),
'firebrick': Color(0xFFB22222),
'golden_rod': Color(0xFFDAA520),
'green': Color(0xFF008000),
'hot_pink': Color(0xFFFF69B4),
'orange_red': Color(0xFFFF4500),
'red': Color(0xFFFF0000),
'sea_green': Color(0xFF2E8B57),
'spring_green': Color(0xFF00FF7F),
'yellow_green': Color(0xFF9ACD32),
};

View file

@ -1,125 +1,58 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:advanced_in_app_review/advanced_in_app_review.dart';
import 'package:app_links/app_links.dart';
import 'package:dio/dio.dart';
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:firebase_performance/firebase_performance.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.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/dio_client.dart';
import 'package:frosty/apis/ffz_api.dart';
import 'package:frosty/apis/seventv_api.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/apis/twitch_auth_interceptor.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:frosty/apis/unauthorized_interceptor.dart';
import 'package:frosty/cache_manager.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/firebase_options.dart';
import 'package:frosty/screens/channel/channel.dart';
import 'package:frosty/screens/home/home.dart';
import 'package:frosty/screens/onboarding/onboarding_intro.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/stores/global_assets_store.dart';
import 'package:frosty/theme.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:http/http.dart';
import 'package:mobx/mobx.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
/// Errors that are expected on a mobile client — transient network/I/O, plugin
/// teardown races, and image fetch failures — and therefore shouldn't be
/// reported to Crashlytics as fatal crashes (which would tank the crash-free
/// metric and bucket unrelated errors together under the global handlers).
///
/// This is a last-resort net. The layers that own these errors already record
/// them non-fatally with precise reasons (e.g. base_api_client, the IRC
/// websocket handlers); this only catches what escapes that local handling.
bool _isNonFatalError(Object error) {
return error is SocketException ||
error is HttpException ||
error is WebSocketChannelException ||
error is ApiException ||
error is MissingPluginException ||
error is NetworkImageLoadException ||
(error is DioException && error.type != DioExceptionType.badResponse);
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
CustomCacheManager.removeOrphanedCacheFiles();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
// Route uncaught framework errors to Crashlytics. Non-crashing UI errors
// (layout overflows, image-load failures) and other expected transient
// failures are recorded as non-fatal so they don't inflate the crash count.
FlutterError.onError = (details) {
// Run the cheap type checks first; only fall back to the diagnostics string
// (RenderFlex overflow has no typed signal) when those miss.
final isNonFatal =
_isNonFatalError(details.exception) ||
details.exceptionAsString().contains('overflowed');
if (isNonFatal) {
FirebaseCrashlytics.instance.recordFlutterError(details);
} else {
FirebaseCrashlytics.instance.recordFlutterFatalError(details);
}
};
// Pass uncaught asynchronous errors to Crashlytics. Expected transient errors
// are recorded non-fatally; everything else stays fatal. The reason gives the
// grouping engine a real signal so distinct errors don't collapse into one
// catch-all issue under this handler frame.
PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError(
error,
stack,
reason: error.runtimeType.toString(),
fatal: !_isNonFatalError(error),
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
// Pass all uncaught "fatal" errors from the framework to Crashlytics
FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;
// Pass all uncaught asynchronous errors that aren't handled by the Flutter framework to Crashlytics
PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
return true;
};
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;
// Workaround for clearing stored tokens on uninstall.
// If first time running app, will clear all tokens in the secure storage.
// Run non-blocking to avoid ANR on slow Android devices (especially Android 14).
if (firstRun) {
debugPrint('Clearing secure storage...');
const storage = FlutterSecureStorage();
// Don't block - run in background after app starts.
// This is safe because first run means no active session depends on this data.
unawaited(
storage.deleteAll().catchError((e) {
debugPrint('Error clearing secure storage: $e');
}),
);
await storage.deleteAll();
}
await initUtils();
@ -131,70 +64,30 @@ void main() async {
// Initialize a settings store from the settings JSON string.
final settingsStore = SettingsStore.fromJson(jsonDecode(userSettings));
// Disable Firebase collection in debug builds to avoid polluting production data.
// In release builds, respect the user's preference.
if (kDebugMode) {
FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(false);
FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(false);
FirebasePerformance.instance.setPerformanceCollectionEnabled(false);
} else {
final shareEnabled = settingsStore.shareCrashLogsAndAnalytics;
FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(shareEnabled);
FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(shareEnabled);
FirebasePerformance.instance.setPerformanceCollectionEnabled(shareEnabled);
}
// Create a MobX reaction that will save the settings on disk every time they are changed.
autorun((_) => prefs.setString('settings', jsonEncode(settingsStore)));
/// Initialize API services with a common Dio client.
/// Initialize API services with a common client.
/// This will prevent every request from creating a new client instance.
final dioClient = DioClient.createClient();
// Create API services
final twitchApiService = TwitchApi(dioClient);
final bttvApiService = BTTVApi(dioClient);
final ffzApiService = FFZApi(dioClient);
final sevenTVApiService = SevenTVApi(dioClient);
final twitchGqlApiService = TwitchGqlApi(dioClient);
// Create global assets store (shared cache for global emotes/badges)
final globalAssetsStore = GlobalAssetsStore(
twitchApi: twitchApiService,
bttvApi: bttvApiService,
ffzApi: ffzApiService,
sevenTVApi: sevenTVApiService,
);
final client = Client();
final twitchApiService = TwitchApi(client);
final bttvApiService = BTTVApi(client);
final ffzApiService = FFZApi(client);
final sevenTVApiService = SevenTVApi(client);
// Create and initialize the authentication store
final authStore = AuthStore(
twitchApi: twitchApiService,
twitchGqlApi: twitchGqlApiService,
);
// Add the auth interceptor to the Dio client after AuthStore creation
dioClient.interceptors.add(TwitchAuthInterceptor(authStore));
// Add the unauthorized interceptor to catch 401 errors
dioClient.interceptors.add(UnauthorizedInterceptor(authStore));
final authStore = AuthStore(twitchApi: twitchApiService);
await authStore.init();
FirebaseCrashlytics.instance.setCustomKey('is_logged_in', authStore.isLoggedIn);
if (authStore.isLoggedIn && authStore.user.details != null) {
FirebaseCrashlytics.instance.setUserIdentifier(authStore.user.details!.id);
}
runApp(
MultiProvider(
providers: [
Provider<AuthStore>.value(value: authStore),
Provider<SettingsStore>.value(value: settingsStore),
Provider<TwitchApi>.value(value: twitchApiService),
Provider<BTTVApi>.value(value: bttvApiService),
Provider<FFZApi>.value(value: ffzApiService),
Provider<SevenTVApi>.value(value: sevenTVApiService),
Provider<TwitchGqlApi>.value(value: twitchGqlApiService),
Provider<GlobalAssetsStore>.value(value: globalAssetsStore),
Provider<AuthStore>(create: (_) => authStore),
Provider<SettingsStore>(create: (_) => settingsStore),
Provider<TwitchApi>(create: (_) => twitchApiService),
Provider<BTTVApi>(create: (_) => bttvApiService),
Provider<FFZApi>(create: (_) => ffzApiService),
Provider<SevenTVApi>(create: (_) => sevenTVApiService),
],
child: MyApp(firstRun: firstRun),
),
@ -207,16 +100,16 @@ final navigatorKey = GlobalKey<NavigatorState>();
class MyApp extends StatefulWidget {
final bool firstRun;
const MyApp({super.key, this.firstRun = false});
const MyApp({
super.key,
this.firstRun = false,
});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
final AppLinks _appLinks = AppLinks();
StreamSubscription<Uri>? _linkSubscription;
@override
void initState() {
super.initState();
@ -227,8 +120,6 @@ class _MyAppState extends State<MyApp> {
.setMinLaunchTimes(5)
.setMinSecondsBeforeShowDialog(3)
.monitor();
_initDeepLinks();
}
@override
@ -236,11 +127,12 @@ class _MyAppState extends State<MyApp> {
return Observer(
builder: (context) {
final settingsStore = context.read<SettingsStore>();
final themes = FrostyThemes(
colorSchemeSeed: Color(settingsStore.accentColor),
);
final themes =
FrostyThemes(colorSchemeSeed: Color(settingsStore.accentColor));
return MaterialApp(
return Provider<FrostyThemes>(
create: (_) => themes,
child: MaterialApp(
title: 'Frosty',
theme: themes.light,
darkTheme: themes.dark,
@ -249,101 +141,11 @@ class _MyAppState extends State<MyApp> {
: settingsStore.themeType == ThemeType.light
? ThemeMode.light
: ThemeMode.dark,
navigatorObservers: [
FirebaseAnalyticsObserver(
analytics: FirebaseAnalytics.instance,
),
],
home: widget.firstRun ? const OnboardingIntro() : const Home(),
navigatorKey: navigatorKey,
),
);
},
);
}
Future<void> _initDeepLinks() async {
try {
// Handle links when app is already open
_linkSubscription = _appLinks.uriLinkStream.listen((uri) {
handleDeepLink(uri);
});
// Handle the initial link if app was opened from a link.
// Add timeout to prevent indefinite blocking on certain Android lifecycle states.
final initialLink = await _appLinks.getInitialLink().timeout(
const Duration(seconds: 3),
onTimeout: () => null,
);
if (initialLink != null) {
handleDeepLink(initialLink);
}
} catch (e) {
debugPrint('Failed to initialize deep links: $e');
}
}
Future<void> handleDeepLink(Uri uri) async {
final failureSnackbar = SnackBar(
content: AlertMessage(
message: 'Unable to navigate to \'$uri\'',
centered: false,
trailingIcon: Icons.open_in_browser_rounded,
// Fallback, allow user to open URL outside app
onTrailingIconPressed: () async {
await launchUrl(
uri,
mode: LaunchMode.inAppWebView, // Force browser
);
},
),
);
// Handle channel links
if (uri.pathSegments.isNotEmpty) {
final channelName = uri.pathSegments.first;
try {
final twitchApi = context.read<TwitchApi>();
final user = await twitchApi.getUser(userLogin: channelName);
final route = MaterialPageRoute(
settings: const RouteSettings(name: VideoChat.routeName),
builder: (context) => VideoChat(
userId: user.id,
userName: user.displayName,
userLogin: user.login,
),
);
if (navigatorKey.currentState == null) return;
WidgetsBinding.instance.addPostFrameCallback(
(_) => navigatorKey.currentState?.push(route),
);
} catch (e) {
// If we get here, there was most likely an error with the Twitch API call and/or this isn't really a channel link
debugPrint('Failed to open link $uri due to error: $e');
if (navigatorKey.currentContext == null) return;
ScaffoldMessenger.of(
navigatorKey.currentContext!,
).showSnackBar(failureSnackbar);
}
}
// TODO: Here we can implement handlers for other types of links
else {
// If we get here, it's a link format that we're unable to handle
if (navigatorKey.currentContext == null) return;
ScaffoldMessenger.of(
navigatorKey.currentContext!,
).showSnackBar(failureSnackbar);
}
}
@override
void dispose() {
_linkSubscription?.cancel();
super.dispose();
}
}

View file

@ -35,7 +35,12 @@ class BadgeInfoFFZ {
final String color;
final BadgeUrlsFFZ urls;
const BadgeInfoFFZ(this.id, this.title, this.color, this.urls);
const BadgeInfoFFZ(
this.id,
this.title,
this.color,
this.urls,
);
factory BadgeInfoFFZ.fromJson(Map<String, dynamic> json) =>
_$BadgeInfoFFZFromJson(json);
@ -50,7 +55,11 @@ class BadgeUrlsFFZ {
@JsonKey(name: '4')
final String url4x;
const BadgeUrlsFFZ(this.url1x, this.url2x, this.url4x);
const BadgeUrlsFFZ(
this.url1x,
this.url2x,
this.url4x,
);
factory BadgeUrlsFFZ.fromJson(Map<String, dynamic> json) =>
_$BadgeUrlsFFZFromJson(json);
@ -62,7 +71,11 @@ class BadgeInfo7TV {
final List<List<String>> urls;
final List<String> users;
BadgeInfo7TV(this.tooltip, this.urls, this.users);
BadgeInfo7TV(
this.tooltip,
this.urls,
this.users,
);
factory BadgeInfo7TV.fromJson(Map<String, dynamic> json) =>
_$BadgeInfo7TVFromJson(json);
@ -73,7 +86,10 @@ class BadgeInfoBTTV {
final String providerId;
final BadgeDetailsBTTV badge;
BadgeInfoBTTV(this.providerId, this.badge);
BadgeInfoBTTV(
this.providerId,
this.badge,
);
factory BadgeInfoBTTV.fromJson(Map<String, dynamic> json) =>
_$BadgeInfoBTTVFromJson(json);
@ -84,7 +100,10 @@ class BadgeDetailsBTTV {
final String description;
final String svg;
BadgeDetailsBTTV(this.description, this.svg);
BadgeDetailsBTTV(
this.description,
this.svg,
);
factory BadgeDetailsBTTV.fromJson(Map<String, dynamic> json) =>
_$BadgeDetailsBTTVFromJson(json);

View file

@ -21,10 +21,13 @@ BadgeInfoFFZ _$BadgeInfoFFZFromJson(Map<String, dynamic> json) => BadgeInfoFFZ(
json['title'] as String,
json['color'] as String,
BadgeUrlsFFZ.fromJson(json['urls'] as Map<String, dynamic>),
);
);
BadgeUrlsFFZ _$BadgeUrlsFFZFromJson(Map<String, dynamic> json) =>
BadgeUrlsFFZ(json['1'] as String, json['2'] as String, json['4'] as String);
BadgeUrlsFFZ _$BadgeUrlsFFZFromJson(Map<String, dynamic> json) => BadgeUrlsFFZ(
json['1'] as String,
json['2'] as String,
json['4'] as String,
);
BadgeInfo7TV _$BadgeInfo7TVFromJson(Map<String, dynamic> json) => BadgeInfo7TV(
json['tooltip'] as String,
@ -32,7 +35,7 @@ BadgeInfo7TV _$BadgeInfo7TVFromJson(Map<String, dynamic> json) => BadgeInfo7TV(
.map((e) => (e as List<dynamic>).map((e) => e as String).toList())
.toList(),
(json['users'] as List<dynamic>).map((e) => e as String).toList(),
);
);
BadgeInfoBTTV _$BadgeInfoBTTVFromJson(Map<String, dynamic> json) =>
BadgeInfoBTTV(
@ -41,4 +44,7 @@ BadgeInfoBTTV _$BadgeInfoBTTVFromJson(Map<String, dynamic> json) =>
);
BadgeDetailsBTTV _$BadgeDetailsBTTVFromJson(Map<String, dynamic> json) =>
BadgeDetailsBTTV(json['description'] as String, json['svg'] as String);
BadgeDetailsBTTV(
json['description'] as String,
json['svg'] as String,
);

View file

@ -8,7 +8,11 @@ class CategoryTwitch {
final String id;
final String name;
const CategoryTwitch(this.boxArtUrl, this.id, this.name);
const CategoryTwitch(
this.boxArtUrl,
this.id,
this.name,
);
factory CategoryTwitch.fromJson(Map<String, dynamic> json) =>
_$CategoryTwitchFromJson(json);
@ -19,7 +23,10 @@ class CategoriesTwitch {
final List<CategoryTwitch> data;
final Map<String, String>? pagination;
const CategoriesTwitch(this.data, this.pagination);
const CategoriesTwitch(
this.data,
this.pagination,
);
factory CategoriesTwitch.fromJson(Map<String, dynamic> json) =>
_$CategoriesTwitchFromJson(json);

View file

@ -8,19 +8,11 @@ class Channel {
final String broadcasterId;
final String broadcasterLogin;
final String broadcasterName;
final String broadcasterLanguage;
final String title;
final String gameId;
final String gameName;
const Channel({
required this.broadcasterId,
required this.broadcasterLogin,
required this.broadcasterName,
required this.broadcasterLanguage,
required this.title,
required this.gameId,
required this.gameName,
});
factory Channel.fromJson(Map<String, dynamic> json) =>

View file

@ -10,11 +10,7 @@ Channel _$ChannelFromJson(Map<String, dynamic> json) => Channel(
broadcasterId: json['broadcaster_id'] as String,
broadcasterLogin: json['broadcaster_login'] as String,
broadcasterName: json['broadcaster_name'] as String,
broadcasterLanguage: json['broadcaster_language'] as String,
title: json['title'] as String,
gameId: json['game_id'] as String,
gameName: json['game_name'] as String,
);
);
ChannelQuery _$ChannelQueryFromJson(Map<String, dynamic> json) => ChannelQuery(
broadcasterLogin: json['broadcaster_login'] as String,
@ -22,4 +18,4 @@ ChannelQuery _$ChannelQueryFromJson(Map<String, dynamic> json) => ChannelQuery(
id: json['id'] as String,
isLive: json['is_live'] as bool,
startedAt: json['started_at'] as String,
);
);

View file

@ -7,7 +7,10 @@ class ChatUsers {
final int chatterCount;
final Chatters chatters;
ChatUsers(this.chatterCount, this.chatters);
ChatUsers(
this.chatterCount,
this.chatters,
);
factory ChatUsers.fromJson(Map<String, dynamic> json) =>
_$ChatUsersFromJson(json);

View file

@ -9,7 +9,7 @@ part of 'chatters.dart';
ChatUsers _$ChatUsersFromJson(Map<String, dynamic> json) => ChatUsers(
(json['chatter_count'] as num).toInt(),
Chatters.fromJson(json['chatters'] as Map<String, dynamic>),
);
);
Chatters _$ChattersFromJson(Map<String, dynamic> json) => Chatters(
(json['broadcaster'] as List<dynamic>).map((e) => e as String).toList(),
@ -19,4 +19,4 @@ Chatters _$ChattersFromJson(Map<String, dynamic> json) => Chatters(
(json['admins'] as List<dynamic>).map((e) => e as String).toList(),
(json['global_mods'] as List<dynamic>).map((e) => e as String).toList(),
(json['viewers'] as List<dynamic>).map((e) => e as String).toList(),
);
);

View file

@ -12,7 +12,12 @@ class EmoteTwitch {
final String? emoteType;
final String? ownerId;
const EmoteTwitch(this.id, this.name, this.emoteType, this.ownerId);
const EmoteTwitch(
this.id,
this.name,
this.emoteType,
this.ownerId,
);
factory EmoteTwitch.fromJson(Map<String, dynamic> json) =>
_$EmoteTwitchFromJson(json);
@ -24,7 +29,10 @@ class EmoteBTTV {
final String id;
final String code;
const EmoteBTTV(this.id, this.code);
const EmoteBTTV(
this.id,
this.code,
);
factory EmoteBTTV.fromJson(Map<String, dynamic> json) =>
_$EmoteBTTVFromJson(json);
@ -35,7 +43,10 @@ class EmoteBTTVChannel {
final List<EmoteBTTV> channelEmotes;
final List<EmoteBTTV> sharedEmotes;
const EmoteBTTVChannel(this.channelEmotes, this.sharedEmotes);
const EmoteBTTVChannel(
this.channelEmotes,
this.sharedEmotes,
);
factory EmoteBTTVChannel.fromJson(Map<String, dynamic> json) =>
_$EmoteBTTVChannelFromJson(json);
@ -48,7 +59,11 @@ class RoomFFZ {
final ImagesFFZ? vipBadge;
final ImagesFFZ? modUrls;
const RoomFFZ(this.set, this.vipBadge, this.modUrls);
const RoomFFZ(
this.set,
this.vipBadge,
this.modUrls,
);
factory RoomFFZ.fromJson(Map<String, dynamic> json) =>
_$RoomFFZFromJson(json);
@ -63,7 +78,11 @@ class ImagesFFZ {
@JsonKey(name: '4')
final String? url4x;
const ImagesFFZ(this.url1x, this.url2x, this.url4x);
const ImagesFFZ(
this.url1x,
this.url2x,
this.url4x,
);
factory ImagesFFZ.fromJson(Map<String, dynamic> json) =>
_$ImagesFFZFromJson(json);
@ -74,7 +93,10 @@ class OwnerFFZ {
final String displayName;
final String name;
const OwnerFFZ({required this.displayName, required this.name});
const OwnerFFZ({
required this.displayName,
required this.name,
});
factory OwnerFFZ.fromJson(Map<String, dynamic> json) =>
_$OwnerFFZFromJson(json);
@ -108,7 +130,11 @@ class Emote7TV {
final String name;
final Emote7TVData data;
const Emote7TV(this.id, this.name, this.data);
const Emote7TV(
this.id,
this.name,
this.data,
);
factory Emote7TV.fromJson(Map<String, dynamic> json) =>
_$Emote7TVFromJson(json);
@ -119,7 +145,10 @@ class Owner7TV {
final String username;
final String displayName;
const Owner7TV({required this.username, required this.displayName});
const Owner7TV({
required this.username,
required this.displayName,
});
factory Owner7TV.fromJson(Map<String, dynamic> json) =>
_$Owner7TVFromJson(json);
@ -133,7 +162,13 @@ class Emote7TVData {
final Owner7TV? owner;
final Emote7TVHost host;
const Emote7TVData(this.id, this.name, this.flags, this.owner, this.host);
const Emote7TVData(
this.id,
this.name,
this.flags,
this.owner,
this.host,
);
factory Emote7TVData.fromJson(Map<String, dynamic> json) =>
_$Emote7TVDataFromJson(json);
@ -144,7 +179,10 @@ class Emote7TVHost {
final String url;
final List<Emote7TVFile> files;
Emote7TVHost(this.url, this.files);
Emote7TVHost(
this.url,
this.files,
);
factory Emote7TVHost.fromJson(Map<String, dynamic> json) =>
_$Emote7TVHostFromJson(json);
@ -157,7 +195,12 @@ class Emote7TVFile {
final int height;
final String format;
Emote7TVFile(this.name, this.width, this.height, this.format);
Emote7TVFile(
this.name,
this.width,
this.height,
this.format,
);
factory Emote7TVFile.fromJson(Map<String, dynamic> json) =>
_$Emote7TVFileFromJson(json);
@ -211,8 +254,7 @@ class Emote {
zeroWidth: false,
width: emote.width,
height: emote.height,
url:
emote.animated?.url4x ??
url: emote.animated?.url4x ??
emote.animated?.url2x ??
emote.animated?.url1x ??
emote.urls.url4x ??

View file

@ -11,10 +11,12 @@ EmoteTwitch _$EmoteTwitchFromJson(Map<String, dynamic> json) => EmoteTwitch(
json['name'] as String,
json['emote_type'] as String?,
json['owner_id'] as String?,
);
);
EmoteBTTV _$EmoteBTTVFromJson(Map<String, dynamic> json) =>
EmoteBTTV(json['id'] as String, json['code'] as String);
EmoteBTTV _$EmoteBTTVFromJson(Map<String, dynamic> json) => EmoteBTTV(
json['id'] as String,
json['code'] as String,
);
EmoteBTTVChannel _$EmoteBTTVChannelFromJson(Map<String, dynamic> json) =>
EmoteBTTVChannel(
@ -34,15 +36,18 @@ RoomFFZ _$RoomFFZFromJson(Map<String, dynamic> json) => RoomFFZ(
json['mod_urls'] == null
? null
: ImagesFFZ.fromJson(json['mod_urls'] as Map<String, dynamic>),
);
);
ImagesFFZ _$ImagesFFZFromJson(Map<String, dynamic> json) =>
ImagesFFZ(json['1'] as String, json['2'] as String?, json['4'] as String?);
ImagesFFZ _$ImagesFFZFromJson(Map<String, dynamic> json) => ImagesFFZ(
json['1'] as String,
json['2'] as String?,
json['4'] as String?,
);
OwnerFFZ _$OwnerFFZFromJson(Map<String, dynamic> json) => OwnerFFZ(
displayName: json['display_name'] as String,
name: json['name'] as String,
);
);
EmoteFFZ _$EmoteFFZFromJson(Map<String, dynamic> json) => EmoteFFZ(
json['name'] as String,
@ -53,18 +58,18 @@ EmoteFFZ _$EmoteFFZFromJson(Map<String, dynamic> json) => EmoteFFZ(
json['animated'] == null
? null
: ImagesFFZ.fromJson(json['animated'] as Map<String, dynamic>),
);
);
Emote7TV _$Emote7TVFromJson(Map<String, dynamic> json) => Emote7TV(
json['id'] as String,
json['name'] as String,
Emote7TVData.fromJson(json['data'] as Map<String, dynamic>),
);
);
Owner7TV _$Owner7TVFromJson(Map<String, dynamic> json) => Owner7TV(
username: json['username'] as String,
displayName: json['display_name'] as String,
);
);
Emote7TVData _$Emote7TVDataFromJson(Map<String, dynamic> json) => Emote7TVData(
json['id'] as String,
@ -74,21 +79,21 @@ Emote7TVData _$Emote7TVDataFromJson(Map<String, dynamic> json) => Emote7TVData(
? null
: Owner7TV.fromJson(json['owner'] as Map<String, dynamic>),
Emote7TVHost.fromJson(json['host'] as Map<String, dynamic>),
);
);
Emote7TVHost _$Emote7TVHostFromJson(Map<String, dynamic> json) => Emote7TVHost(
json['url'] as String,
(json['files'] as List<dynamic>)
.map((e) => Emote7TVFile.fromJson(e as Map<String, dynamic>))
.toList(),
);
);
Emote7TVFile _$Emote7TVFileFromJson(Map<String, dynamic> json) => Emote7TVFile(
json['name'] as String,
(json['width'] as num).toInt(),
(json['height'] as num).toInt(),
json['format'] as String,
);
);
Emote _$EmoteFromJson(Map<String, dynamic> json) => Emote(
name: json['name'] as String,
@ -101,7 +106,7 @@ Emote _$EmoteFromJson(Map<String, dynamic> json) => Emote(
ownerDisplayName: json['ownerDisplayName'] as String?,
ownerUsername: json['ownerUsername'] as String?,
ownerId: json['ownerId'] as String?,
);
);
Map<String, dynamic> _$EmoteToJson(Emote instance) => <String, dynamic>{
'name': instance.name,
@ -114,7 +119,7 @@ Map<String, dynamic> _$EmoteToJson(Emote instance) => <String, dynamic>{
'ownerDisplayName': instance.ownerDisplayName,
'ownerUsername': instance.ownerUsername,
'ownerId': instance.ownerId,
};
};
const _$EmoteTypeEnumMap = {
EmoteType.twitchBits: 'twitchBits',

View file

@ -10,7 +10,11 @@ class SevenTVEvent {
final int? t;
final SevenTVEventData d;
const SevenTVEvent({required this.op, this.t, required this.d});
const SevenTVEvent({
required this.op,
this.t,
required this.d,
});
factory SevenTVEvent.fromJson(Map<String, dynamic> json) =>
_$SevenTVEventFromJson(json);
@ -25,7 +29,11 @@ class SevenTVEventData {
@JsonKey(includeToJson: false)
final SevenTVEventEmoteSetBody? body;
const SevenTVEventData({required this.type, this.condition, this.body});
const SevenTVEventData({
required this.type,
this.condition,
this.body,
});
factory SevenTVEventData.fromJson(Map<String, dynamic> json) =>
_$SevenTVEventDataFromJson(json);
@ -38,7 +46,10 @@ class SevenTVEventUpdatedEmote {
final Emote7TV? value;
final Emote7TV? oldValue;
const SevenTVEventUpdatedEmote({this.value, this.oldValue});
const SevenTVEventUpdatedEmote({
this.value,
this.oldValue,
});
factory SevenTVEventUpdatedEmote.fromJson(Map<String, dynamic> json) =>
_$SevenTVEventUpdatedEmoteFromJson(json);

View file

@ -10,10 +10,14 @@ SevenTVEvent _$SevenTVEventFromJson(Map<String, dynamic> json) => SevenTVEvent(
op: (json['op'] as num).toInt(),
t: (json['t'] as num?)?.toInt(),
d: SevenTVEventData.fromJson(json['d'] as Map<String, dynamic>),
);
);
Map<String, dynamic> _$SevenTVEventToJson(SevenTVEvent instance) =>
<String, dynamic>{'op': instance.op, 't': ?instance.t, 'd': instance.d};
<String, dynamic>{
'op': instance.op,
if (instance.t case final value?) 't': value,
'd': instance.d,
};
SevenTVEventData _$SevenTVEventDataFromJson(Map<String, dynamic> json) =>
SevenTVEventData(
@ -24,32 +28,36 @@ SevenTVEventData _$SevenTVEventDataFromJson(Map<String, dynamic> json) =>
body: json['body'] == null
? null
: SevenTVEventEmoteSetBody.fromJson(
json['body'] as Map<String, dynamic>,
),
json['body'] as Map<String, dynamic>),
);
Map<String, dynamic> _$SevenTVEventDataToJson(SevenTVEventData instance) =>
<String, dynamic>{'type': instance.type, 'condition': instance.condition};
<String, dynamic>{
'type': instance.type,
'condition': instance.condition,
};
SevenTVEventUpdatedEmote _$SevenTVEventUpdatedEmoteFromJson(
Map<String, dynamic> json,
) => SevenTVEventUpdatedEmote(
Map<String, dynamic> json) =>
SevenTVEventUpdatedEmote(
value: json['value'] == null
? null
: Emote7TV.fromJson(json['value'] as Map<String, dynamic>),
oldValue: json['old_value'] == null
? null
: Emote7TV.fromJson(json['old_value'] as Map<String, dynamic>),
);
);
SevenTVEventEmoteSetBody _$SevenTVEventEmoteSetBodyFromJson(
Map<String, dynamic> json,
) => SevenTVEventEmoteSetBody(
Map<String, dynamic> json) =>
SevenTVEventEmoteSetBody(
actor: Owner7TV.fromJson(json['actor'] as Map<String, dynamic>),
pushed: (json['pushed'] as List<dynamic>?)
?.map((e) => SevenTVEventUpdatedEmote.fromJson(e as Map<String, dynamic>))
?.map((e) =>
SevenTVEventUpdatedEmote.fromJson(e as Map<String, dynamic>))
.toList(),
pulled: (json['pulled'] as List<dynamic>?)
?.map((e) => SevenTVEventUpdatedEmote.fromJson(e as Map<String, dynamic>))
?.map((e) =>
SevenTVEventUpdatedEmote.fromJson(e as Map<String, dynamic>))
.toList(),
);
);

View file

@ -1,39 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'followed_channel.g.dart';
// Object for followed Twitch channels.
@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
class FollowedChannel {
final String broadcasterId;
final String broadcasterLogin;
final String broadcasterName;
final DateTime followedAt;
const FollowedChannel({
required this.broadcasterId,
required this.broadcasterLogin,
required this.broadcasterName,
required this.followedAt,
});
factory FollowedChannel.fromJson(Map<String, dynamic> json) =>
_$FollowedChannelFromJson(json);
}
// Response object for followed channels API call.
@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
class FollowedChannels {
final List<FollowedChannel> data;
final Map<String, String> pagination;
final int total;
const FollowedChannels({
required this.data,
required this.pagination,
required this.total,
});
factory FollowedChannels.fromJson(Map<String, dynamic> json) =>
_$FollowedChannelsFromJson(json);
}

View file

@ -1,24 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'followed_channel.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
FollowedChannel _$FollowedChannelFromJson(Map<String, dynamic> json) =>
FollowedChannel(
broadcasterId: json['broadcaster_id'] as String,
broadcasterLogin: json['broadcaster_login'] as String,
broadcasterName: json['broadcaster_name'] as String,
followedAt: DateTime.parse(json['followed_at'] as String),
);
FollowedChannels _$FollowedChannelsFromJson(Map<String, dynamic> json) =>
FollowedChannels(
data: (json['data'] as List<dynamic>)
.map((e) => FollowedChannel.fromJson(e as Map<String, dynamic>))
.toList(),
pagination: Map<String, String>.from(json['pagination'] as Map),
total: (json['total'] as num).toInt(),
);

View file

@ -11,23 +11,12 @@ import 'package:frosty/models/emotes.dart';
import 'package:frosty/models/user.dart';
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/utils.dart' as utils;
import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:frosty/widgets/frosty_cached_network_image.dart';
import 'package:frosty/widgets/frosty_photo_view_dialog.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:frosty/widgets/photo_view.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
// Constants for IRC message rendering
const String _ircActionPrefix = '\x01';
const String _ircActionSuffix = '\x01';
const int _ircActionPrefixLength = 8;
const String _invalidUnicodeChar = '\u{E0000}';
const String _twitchEmoteBaseUrl = 'https://static-cdn.jtvnw.net/emoticons/v2';
const String _twitchIrcServer = 'tmi.twitch.tv';
const String _emoteModeSuffix = '/default/dark/3.0';
const double _emojiSizeOffset = 5.0;
/// The object representation of a Twitch IRC message.
class IRCMessage {
final String raw;
@ -39,8 +28,6 @@ class IRCMessage {
List<String>? split;
bool? action;
bool? mention;
final VoidCallback? actionCallback;
final String? actionLabel;
IRCMessage({
required this.raw,
@ -52,15 +39,8 @@ class IRCMessage {
this.split,
this.action,
this.mention,
this.actionCallback,
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.
static void clearChat({
required List<IRCMessage> messages,
@ -132,25 +112,43 @@ class IRCMessage {
}
}
/// Adds timestamp to the span if enabled
void _addTimestamp(
List<InlineSpan> span,
/// Returns an [InlineSpan] list that corresponds to the badges, username, words, and emotes of the given [IRCMessage].
List<InlineSpan> generateSpan(
BuildContext context, {
TextStyle? style,
TimestampType timestamp,
) {
if (timestamp == TimestampType.disabled) return;
required ChatAssetsStore assetsStore,
required double badgeScale,
required double emoteScale,
required bool launchExternal,
void Function()? onTapName,
void Function(String)? onTapPingedUser,
bool showMessage = true,
Map<String, UserTwitch>? channelIdToUserTwitch,
TimestampType timestamp = TimestampType.disabled,
}) {
final isLightTheme = Theme.of(context).brightness == Brightness.light;
final time =
tags['tmi-sent-ts'] ?? DateTime.now().millisecondsSinceEpoch.toString();
final emoteToObject = assetsStore.emoteToObject;
final twitchBadgeToObject = assetsStore.twitchBadgesToObject;
final ffzUserToBadges = assetsStore.userToFFZBadges;
final sevenTVUserToBadges = assetsStore.userTo7TVBadges;
final bttvUserToBadge = assetsStore.userToBTTVBadges;
final ffzRoomInfo = assetsStore.ffzRoomInfo;
final badgeSize = defaultBadgeSize * badgeScale;
final emoteSize = defaultEmoteSize * emoteScale;
// The span list that will be used to render the chat message
final span = <InlineSpan>[];
if (timestamp != TimestampType.disabled) {
final time = tags['tmi-sent-ts'] ??
DateTime.now().millisecondsSinceEpoch.toString();
final parsedTime = DateTime.fromMillisecondsSinceEpoch(int.parse(time));
final timeText = timestamp == TimestampType.twentyFour
? '${DateFormat.Hm().format(parsedTime)} '
: '${DateFormat('h:mm').format(parsedTime)} ';
if (timestamp == TimestampType.twentyFour) {
span.add(
TextSpan(
text: timeText,
text: '${DateFormat.Hm().format(parsedTime)} ',
style: style?.copyWith(
color: style.color?.withValues(alpha: 0.5),
fontFeatures: [FontFeature.tabularFigures()],
@ -159,14 +157,19 @@ class IRCMessage {
);
}
/// Adds historical and channel source badges to the span
void _addHistoricalAndChannelBadges(
BuildContext context,
List<InlineSpan> span,
double badgeSize,
Map<String, UserTwitch>? channelIdToUserTwitch,
String? currentChannelId,
) {
if (timestamp == TimestampType.twelve) {
span.add(
TextSpan(
text: '${DateFormat('h:mm').format(parsedTime)} ',
style: style?.copyWith(
color: style.color?.withValues(alpha: 0.5),
fontFeatures: [FontFeature.tabularFigures()],
),
),
);
}
}
final isHistorical = tags['historical'] == '1';
if (isHistorical) {
span.add(
@ -176,34 +179,25 @@ class IRCMessage {
message: 'Historical message',
preferBelow: false,
triggerMode: TooltipTriggerMode.tap,
child: Icon(
Icons.history_rounded,
size: badgeSize,
color:
Theme.of(context).iconTheme.color?.withValues(alpha: 0.5) ??
Theme.of(context).colorScheme.onSurfaceVariant.withValues(
alpha: 0.5,
),
),
child: Icon(Icons.history_rounded, size: badgeSize),
),
),
);
span.add(const TextSpan(text: ' '));
}
final sourceChannelUser = channelIdToUserTwitch?[sourceChannelId];
final sourceChannelId = tags['source-room-id'] ?? tags['room-id'];
final sourceChannelUser = channelIdToUserTwitch != null
? channelIdToUserTwitch[sourceChannelId]
: null;
if (sourceChannelUser != null) {
final isCurrentChannel = sourceChannelId == currentChannelId;
span.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Tooltip(
triggerMode: TooltipTriggerMode.tap,
preferBelow: false,
message: isCurrentChannel
? 'Sent from ${sourceChannelUser.displayName} (current channel)'
: 'Sent from ${sourceChannelUser.displayName}',
message: 'Sent from ${sourceChannelUser.displayName}',
child: CachedNetworkImage(
cacheManager: CustomCacheManager.instance,
imageUrl: sourceChannelUser.profileImageUrl,
@ -212,22 +206,8 @@ class IRCMessage {
height: badgeSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: isCurrentChannel
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 1.5,
)
: null,
),
child: Container(
margin: isCurrentChannel ? const EdgeInsets.all(1.5) : null,
decoration: BoxDecoration(
shape: BoxShape.circle,
image: DecorationImage(
image: imageProvider,
fit: BoxFit.cover,
),
),
image:
DecorationImage(image: imageProvider, fit: BoxFit.cover),
),
),
placeholder: (context, url) => Container(
@ -235,12 +215,7 @@ class IRCMessage {
height: badgeSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: isCurrentChannel
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 1.5,
)
: null,
color: Colors.grey,
),
),
),
@ -249,47 +224,32 @@ class IRCMessage {
);
span.add(const TextSpan(text: ' '));
}
}
/// Adds all user badges (Twitch, FFZ, BTTV, 7TV) to the span
bool _addUserBadges(
BuildContext context,
List<InlineSpan> span,
ChatAssetsStore assetsStore,
double badgeSize,
bool launchExternal,
bool isHistorical,
) {
// Indicator to skip adding the bot badges later when adding the rest of FFZ badges.
var skipBot = false;
final twitchBadgeToObject = assetsStore.twitchBadgesToObject;
final ffzUserToBadges = assetsStore.userToFFZBadges;
final sevenTVUserToBadges = assetsStore.userTo7TVBadges;
final bttvUserToBadge = assetsStore.userToBTTVBadges;
final ffzRoomInfo = assetsStore.ffzRoomInfo;
final ffzUserBadges = ffzUserToBadges[tags['user-id']];
final twitchBadges = tags['badges']?.split(',');
// Add Twitch badges
// Pasrse and add the Twitch badges to the span if they exist.
if (twitchBadges != null) {
for (final badge in twitchBadges) {
final badgeInfo = twitchBadgeToObject[badge];
if (badgeInfo != null) {
var badgeUrl = badgeInfo.url;
// Handle custom FFZ mod badge
// Add custom FFZ mod badge if it exists.
if (badgeInfo.name == 'Moderator' &&
(ffzUserBadges != null || ffzRoomInfo?.modUrls != null)) {
final botBadge = ffzUserBadges?.firstWhereOrNull(
(element) => element.name == 'Bot',
);
// Check if mod is bot.
final botBadge = ffzUserBadges
?.firstWhereOrNull((element) => element.name == 'Bot');
// Check if user has bot badge or room has custom FFZ mod badges
if (botBadge != null) {
badgeUrl = botBadge.url;
skipBot = true;
} else if (ffzRoomInfo?.modUrls != null) {
badgeUrl =
ffzRoomInfo!.modUrls?.url4x ??
badgeUrl = ffzRoomInfo!.modUrls?.url4x ??
ffzRoomInfo.modUrls?.url2x ??
ffzRoomInfo.modUrls!.url1x;
}
@ -313,10 +273,9 @@ class IRCMessage {
continue;
}
// Handle custom FFZ VIP badge
// Add custom FFZ vip badge if it exists
if (badgeInfo.name == 'VIP' && ffzRoomInfo?.vipBadge != null) {
badgeUrl =
ffzRoomInfo!.vipBadge?.url4x ??
badgeUrl = ffzRoomInfo!.vipBadge?.url4x ??
ffzRoomInfo.vipBadge?.url2x ??
ffzRoomInfo.vipBadge!.url1x;
}
@ -340,7 +299,7 @@ class IRCMessage {
}
}
// Add FFZ badges
// Add FFZ badges to span
if (ffzUserBadges != null) {
for (final badge in ffzUserBadges) {
final color = Color(int.parse(badge.color!.replaceFirst('#', '0xFF')));
@ -375,7 +334,7 @@ class IRCMessage {
}
}
// Add BTTV badges
// Add BTTV badges to span
final userBTTVBadge = bttvUserToBadge[tags['user-id']];
if (userBTTVBadge != null) {
span.add(
@ -390,7 +349,7 @@ class IRCMessage {
span.add(const TextSpan(text: ' '));
}
// Add 7TV badges
// Add 7TV badges to end of badges span
final user7TVBadges = sevenTVUserToBadges[tags['user-id']];
if (user7TVBadges != null) {
for (final badge in user7TVBadges) {
@ -406,200 +365,115 @@ class IRCMessage {
}
}
return skipBot;
}
/// Adds the username to the span with proper color and styling
void _addUsername(
List<InlineSpan> span,
BuildContext context,
void Function()? onTapName,
) {
var color = Color(
int.parse((tags['color'] ?? '#868686').replaceFirst('#', '0xFF')),
);
// Adjust color for theme contrast using the common utility
color = utils.adjustChatNameColor(context, color);
final hsl = HSLColor.fromColor(color);
if (isLightTheme == true) {
if (hsl.lightness >= 0.5) {
color = hsl
.withLightness(hsl.lightness + ((0 - hsl.lightness) * 0.5))
.toColor();
}
} else {
if (hsl.lightness <= 0.5) {
color = hsl
.withLightness(hsl.lightness + ((1 - hsl.lightness) * 0.5))
.toColor();
}
}
// Add the display name (username) to the span and apply the onLongPressName callback.
final displayName = tags['display-name']!;
span.add(
TextSpan(
text: user != null
? utils.getReadableName(displayName, user!)
: displayName,
style: TextStyle(color: color, fontWeight: FontWeight.bold),
text: user != null ? getReadableName(displayName, user!) : displayName,
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
),
recognizer: TapGestureRecognizer()..onTap = onTapName,
),
);
// Add the colon separator between the username and their message
// Add the colon separator between the username and their message to the span.
if (action == false) span.add(const TextSpan(text: ':'));
}
/// Adds message content with emotes, emojis and text processing
void _addMessageContent(
BuildContext context,
List<InlineSpan> span,
Map<String, Emote> emoteToObject,
double emoteSize,
double emoteScale,
bool showMessage,
bool launchExternal,
TextStyle? textStyle,
void Function(String)? onTapPingedUser,
void Function()? onTapDeletedMessage, {
Color? linkColor,
}) {
// Italicize the text if it was called with an IRC Action (e.g., "/me").
final textStyle =
action == true ? const TextStyle(fontStyle: FontStyle.italic) : style;
if (!showMessage) {
span.add(
TextSpan(
text: ' <message deleted>',
style: onTapDeletedMessage != null
? textStyle?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500,
)
: null,
recognizer: onTapDeletedMessage != null
? (TapGestureRecognizer()..onTap = onTapDeletedMessage)
: null,
),
);
return;
}
// Check if the message is a reply. If it is, remove the reply username from the message.
span.add(const TextSpan(text: ' <message deleted>'));
} else {
// Check if the message is a reply. If it is, remove the reply username (first word) from the message.
final words = tags.containsKey('reply-parent-display-name')
? split?.sublist(1)
: split;
// Add the message and any emotes to the span.
if (words != null) {
// Keep a local span which will be reversed and added to the final span.
final localSpan = <InlineSpan>[];
// Iterate through the words in reverse-order.
// Index starts at the last word in the message.
var index = words.length - 1;
// Terminate after the first word in the message reached.
while (index != -1) {
// Check if current word is a valid emote.
final word = words[index];
final emote = emoteToObject[word] ?? localEmotes?[word];
if (emote != null) {
// If the emote is zero-width and not the first word, process the stack.
if (emote.zeroWidth && index != 0) {
index = _processZeroWidthEmoteStack(
context,
localSpan,
words,
index,
emote,
emoteToObject,
emoteSize,
emoteScale,
textStyle,
launchExternal,
linkColor: linkColor,
);
} else {
localSpan.add(
_createEmoteSpan(
context,
emote: emote,
height: emote.height != null
? emote.height! * emoteScale
: emoteSize,
width: emote.width != null ? emote.width! * emoteScale : null,
launchExternal: launchExternal,
),
);
localSpan.add(const TextSpan(text: ' '));
index--;
}
} else {
if (regexEmoji.hasMatch(word)) {
localSpan.add(
_createEmojiSpan(
emoji: word,
style: textStyle?.copyWith(
fontSize: emoteSize - _emojiSizeOffset,
),
),
);
} else {
localSpan.add(
_createTextSpan(
text: word,
style: textStyle,
linkColor: linkColor,
launchExternal: launchExternal,
onTapPingedUser: onTapPingedUser,
),
);
}
localSpan.add(const TextSpan(text: ' '));
index--;
}
}
// Add the local span reversed to the final span.
span.addAll(localSpan.reversed);
}
}
/// Handles zero-width emote stacking logic and returns updated index
int _processZeroWidthEmoteStack(
BuildContext context,
List<InlineSpan> localSpan,
List<String> words,
int startIndex,
Emote startEmote,
Map<String, Emote> emoteToObject,
double emoteSize,
double emoteScale,
TextStyle? textStyle,
bool launchExternal, {
Color? linkColor,
}) {
final emoteStack = <Emote>[];
var index = startIndex;
// Handle stacking consecutive zero-width emotes.
Emote? nextEmote = startEmote;
Emote? nextEmote = emote;
while (nextEmote != null && nextEmote.zeroWidth && index != 0) {
emoteStack.add(nextEmote);
index--;
nextEmote = emoteToObject[words[index]] ?? localEmotes?[words[index]];
nextEmote =
emoteToObject[words[index]] ?? localEmotes?[words[index]];
}
// If there's one more emote that's NOT zero-width, add it to the base of the stack.
// If there's one more emote thats NOT zero-width, add it to the base of the stack.
if (nextEmote != null) emoteStack.add(nextEmote);
// Check if the next word is an emoji to be added to the stack.
final nextWordIsEmoji = regexEmoji.hasMatch(words[index]);
// Create the stack of emotes with the base emoji if there is one.
// Will be used as the children of the Stack widget.
final children = [
if (nextWordIsEmoji)
Text(
words[index],
style: textStyle?.copyWith(fontSize: emoteSize - _emojiSizeOffset),
style: textStyle?.copyWith(fontSize: emoteSize - 5),
),
...emoteStack.reversed.map(
(emote) => FrostyCachedNetworkImage(
imageUrl: emote.url,
height: emote.height != null ? emote.height! * emoteScale : emoteSize,
width: emote.width != null ? emote.width! * emoteScale : emoteSize,
height: emote.height != null
? emote.height! * emoteScale
: emoteSize,
width: emote.width != null
? emote.width! * emoteScale
: emoteSize,
useFade: false,
),
),
];
// Create the message for the tooltip
final message = emoteStack.reversed.map((emote) => emote.name);
final message = emoteStack.reversed.map(
(emote) => emote.name,
);
final emoji = words[index];
localSpan.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
@ -610,10 +484,15 @@ class IRCMessage {
alignment: AlignmentDirectional.center,
children: [
if (nextWordIsEmoji)
Text(emoji, style: textStyle?.copyWith(fontSize: 40)),
Text(
emoji,
style: textStyle?.copyWith(fontSize: 40),
),
...emoteStack.reversed.map(
(emote) =>
FrostyCachedNetworkImage(imageUrl: emote.url, width: 56),
(emote) => FrostyCachedNetworkImage(
imageUrl: emote.url,
width: 56,
),
),
],
),
@ -634,101 +513,59 @@ class IRCMessage {
),
);
// If the next word is neither an emote nor emoji, add it as a text span.
// If the next word is neither an emote or emoji, add it as a text span.
if (nextEmote == null && !nextWordIsEmoji) {
localSpan.add(const TextSpan(text: ' '));
localSpan.add(
_createTextSpan(
text: words[index],
style: textStyle,
linkColor: linkColor,
launchExternal: launchExternal,
onTapPingedUser: onTapPingedUser,
),
);
}
} else {
localSpan.add(
_createEmoteSpan(
context,
emote: emote,
height: emote.height != null
? emote.height! * emoteScale
: emoteSize,
width: emote.width != null ? emote.width! * emoteScale : null,
launchExternal: launchExternal,
),
);
}
// Add space after the zero-width emote stack (like all other elements)
localSpan.add(const TextSpan(text: ' '));
return index - 1;
} else {
if (regexEmoji.hasMatch(word)) {
localSpan.add(
_createEmojiSpan(
emoji: word,
style: textStyle?.copyWith(fontSize: emoteSize - 5),
),
);
} else {
localSpan.add(
_createTextSpan(
text: word,
style: textStyle,
launchExternal: launchExternal,
onTapPingedUser: onTapPingedUser,
),
);
}
}
/// Returns an [InlineSpan] list that corresponds to the badges, username, words, and emotes of the given [IRCMessage].
List<InlineSpan> generateSpan(
BuildContext context, {
TextStyle? style,
required ChatAssetsStore assetsStore,
required double badgeScale,
required double emoteScale,
required bool launchExternal,
void Function()? onTapName,
void Function(String)? onTapPingedUser,
void Function()? onTapDeletedMessage,
bool showMessage = true,
Map<String, UserTwitch>? channelIdToUserTwitch,
TimestampType timestamp = TimestampType.disabled,
bool forceTimestamp = false,
bool showHistoricalTimestamps = false,
String? currentChannelId,
}) {
final emoteToObject = assetsStore.emoteToObject;
final badgeSize = defaultBadgeSize * badgeScale;
final emoteSize = defaultEmoteSize * emoteScale;
final linkColor = Theme.of(context).colorScheme.primary;
localSpan.add(const TextSpan(text: ' '));
index--;
}
// The span list that will be used to render the chat message
final span = <InlineSpan>[];
// When the user has timestamps disabled globally, fall back to the device's
// locale preference for historical messages (opt-in via setting) and for
// forced contexts like previews.
final isHistorical = tags['historical'] == '1';
final effectiveTimestamp =
(timestamp == TimestampType.disabled &&
((isHistorical && showHistoricalTimestamps) || forceTimestamp))
? (MediaQuery.alwaysUse24HourFormatOf(context)
? TimestampType.twentyFour
: TimestampType.twelve)
: timestamp;
_addTimestamp(span, style, effectiveTimestamp);
_addHistoricalAndChannelBadges(
context,
span,
badgeSize,
channelIdToUserTwitch,
currentChannelId,
);
_addUserBadges(
context,
span,
assetsStore,
badgeSize,
launchExternal,
isHistorical,
);
_addUsername(span, context, onTapName);
// Italicize the text if it was called with an IRC Action (e.g., "/me").
final textStyle = action == true
? const TextStyle(fontStyle: FontStyle.italic)
: style;
_addMessageContent(
context,
span,
emoteToObject,
emoteSize,
emoteScale,
showMessage,
launchExternal,
textStyle,
onTapPingedUser,
onTapDeletedMessage,
linkColor: linkColor,
);
// Add the the local span reversed to the final span.
span.addAll(localSpan.reversed);
}
}
return span;
}
@ -739,7 +576,10 @@ class IRCMessage {
}) {
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Text(emoji, style: style),
child: Text(
emoji,
style: style,
),
);
}
@ -760,7 +600,11 @@ class IRCMessage {
),
);
} else if (isSvg == true) {
return SvgPicture.network(badge.url, height: size, width: size);
return SvgPicture.network(
badge.url,
height: size,
width: size,
);
} else {
return FrostyCachedNetworkImage(
imageUrl: badge.url,
@ -836,7 +680,6 @@ class IRCMessage {
required String text,
required bool launchExternal,
TextStyle? style,
Color? linkColor,
Function(String)? onTapPingedUser,
}) {
if (text.startsWith('@')) {
@ -854,9 +697,9 @@ class IRCMessage {
return TextSpan(
text: text,
style: style?.copyWith(
color: linkColor,
color: Colors.blue,
decoration: TextDecoration.underline,
decorationColor: linkColor,
decorationColor: Colors.blue,
),
recognizer: TapGestureRecognizer()
..onTap = () => launchUrl(
@ -878,7 +721,10 @@ class IRCMessage {
}) {
_showAssetDetailsBottomSheet(
context,
leading: FrostyCachedNetworkImage(imageUrl: emote.url, width: 56),
leading: FrostyCachedNetworkImage(
imageUrl: emote.url,
width: 56,
),
url: emote.url,
title: emote.realName != null
? '${emote.name} (${emote.realName})'
@ -889,7 +735,7 @@ class IRCMessage {
Text(emote.type.toString()),
if (emote.ownerDisplayName != null && emote.ownerUsername != null)
Text(
'by ${utils.getReadableName(emote.ownerDisplayName!, emote.ownerUsername!)}',
'by ${getReadableName(emote.ownerDisplayName!, emote.ownerUsername!)}',
),
],
),
@ -906,7 +752,7 @@ class IRCMessage {
required bool launchExternal,
bool showCopyName = true,
}) {
showModalBottomSheetWithProperFocus(
showModalBottomSheet(
context: context,
builder: (context) => ListView(
shrinkWrap: true,
@ -922,11 +768,12 @@ class IRCMessage {
),
title: Text(
title,
style: const TextStyle(fontWeight: FontWeight.w600),
style: const TextStyle(
fontWeight: FontWeight.w600,
),
),
subtitle: subtitle,
),
const Divider(indent: 16, endIndent: 16),
if (showCopyName)
ListTile(
leading: const Icon(Icons.copy_rounded),
@ -978,31 +825,21 @@ class IRCMessage {
// Obtain the index to break off the tags.
final tagAndIrcMessageDivider = whole.indexOf(' ');
// Without a separating space there are no tags to slice and no message body
// to parse — bail out rather than throw on the substring/index calls below.
if (tagAndIrcMessageDivider == -1) {
return IRCMessage(raw: whole, command: Command.none, tags: const {});
}
// Get the tags substring and escape characters.
// IRC messages escape spaces with \s.
final tags =
whole.substring(1, tagAndIrcMessageDivider).replaceAll('\\s', ' ');
// Parse the IRCv3 tags into a map. The tags section is everything between
// the leading '@' and the first space. We split on the *raw* string: a
// literal ';' is always a separator because escaped semicolons inside a
// value are encoded as `\:` (unescaped per-value below).
// Next, parse and map the tags.
final mappedTags = <String, String>{};
final rawTags = whole.substring(1, tagAndIrcMessageDivider);
for (final tag in rawTags.split(';')) {
// Split on the first '=' only — IRCv3 tag values may contain '='.
final equalsIndex = tag.indexOf('=');
// Loop through each tag and store their key value pairs into the map.
for (final tag in tags.split(';')) {
// Skip if the tag has no value.
if (tag.endsWith('=')) continue;
// Skip valueless tags. Per the IRCv3 spec a missing value (bare `key`)
// and an empty value (`key=`) are equivalent — live Twitch sends the
// latter, the recent-messages API the former.
if (equalsIndex == -1 || equalsIndex == tag.length - 1) continue;
mappedTags[tag.substring(0, equalsIndex)] = _unescapeTagValue(
tag.substring(equalsIndex + 1),
);
final tagSplit = tag.split('=');
mappedTags[tagSplit[0]] = tagSplit[1];
}
// Now we'll parse the message itself.
@ -1016,29 +853,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
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.
// tmi.twitch.tv means the message was sent by Twitch rather than a user, so it is irrelevant.
final prefix = splitMessage[0];
final String? user;
if (prefix == _twitchIrcServer) {
user = mappedTags['login'];
} else {
// Normal prefixes are `nick!user@host`. Fall back to the login tag (or
// null) if the expected `!` is absent so an unexpected prefix shape can
// never throw a RangeError and take down the whole message.
final bangIndex = prefix.indexOf('!');
user = bangIndex == -1
// tmi.twitch.tv means the message was sent by Twitch rather than a user, so will be irrelevant.
final String? user = splitMessage[0] == 'tmi.twitch.tv'
? mappedTags['login']
: prefix.substring(0, bangIndex);
}
: splitMessage[0].substring(0, splitMessage[0].indexOf('!'));
// If there is an associated message, set it.
String? message;
@ -1064,14 +883,12 @@ class IRCMessage {
for (final emoteIdAndPosition in emotes) {
final indexBetweenIdAndPositions = emoteIdAndPosition.indexOf(':');
final emoteId = emoteIdAndPosition.substring(
0,
indexBetweenIdAndPositions,
);
final emoteId =
emoteIdAndPosition.substring(0, indexBetweenIdAndPositions);
// Parse the range to extract the associated word.
// If there is more than one index, use the first.
// Otherwise, use the provided index.
// Parse the range in order to extract the associated word.
// If there are more than one indices, use the first one.
// Else, use the one provided indices.
final String range;
if (emoteIdAndPosition.contains(',')) {
range = emoteIdAndPosition.substring(
@ -1093,7 +910,8 @@ class IRCMessage {
localEmotes[emoteWord] = Emote(
name: emoteWord,
zeroWidth: false,
url: '$_twitchEmoteBaseUrl/$emoteId$_emoteModeSuffix',
url:
'https://static-cdn.jtvnw.net/emoticons/v2/$emoteId/default/dark/3.0',
type: EmoteType.twitchSub,
);
}
@ -1104,10 +922,9 @@ class IRCMessage {
List<String>? split;
if (message != null) {
// Check if IRC actions like "/me" were called.
if (message.startsWith(_ircActionPrefix) &&
message.endsWith(_ircActionSuffix)) {
if (message.startsWith('\x01') && message.endsWith('\x01')) {
action = true;
message = message.substring(_ircActionPrefixLength, message.length - 1);
message = message.substring(8, message.length - 1);
}
// Check if the message mentions the logged-in user
@ -1121,7 +938,7 @@ class IRCMessage {
// Also remove any "INVALID/UNDEFINED" Unicode characters.
// Rendering this character on iOS shows a question mark inside a square.
// This character is used by some clients to bypass restrictions on repeating message.
.map((word) => word.replaceAll(_invalidUnicodeChar, '').trim())
.map((word) => word.replaceAll('\u{E0000}', '').trim())
.where((element) => element != '')
.join(' ');
@ -1199,63 +1016,11 @@ 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({
required String message,
VoidCallback? actionCallback,
String? actionLabel,
}) => IRCMessage(
factory IRCMessage.createNotice({required String message}) => IRCMessage(
raw: '',
tags: {
'tmi-sent-ts': '${DateTime.now().millisecondsSinceEpoch}',
},
tags: {},
command: Command.notice,
message: message,
actionCallback: actionCallback,
actionLabel: actionLabel,
);
}
@ -1304,9 +1069,8 @@ class USERSTATE {
raw: ircMessage.raw,
color: ircMessage.tags['color'] ?? color,
displayName: ircMessage.tags['display-name'] ?? displayName,
// Missing tags must be false — the old `!= '0'` default treated absence as mod.
mod: ircMessage.tags['mod'] == '1',
subscriber: ircMessage.tags['subscriber'] == '1',
mod: ircMessage.tags['mod'] == '0' ? false : true,
subscriber: ircMessage.tags['subscriber'] == '0' ? false : true,
);
}

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

@ -17,5 +17,6 @@ SharedChatSession _$SharedChatSessionFromJson(Map<String, dynamic> json) =>
updatedAt: json['updated_at'] as String,
);
Participant _$ParticipantFromJson(Map<String, dynamic> json) =>
Participant(broadcasterId: json['broadcaster_id'] as String);
Participant _$ParticipantFromJson(Map<String, dynamic> json) => Participant(
broadcasterId: json['broadcaster_id'] as String,
);

View file

@ -15,14 +15,6 @@ class StreamTwitch {
final String startedAt;
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(
this.userId,
this.userLogin,
@ -32,10 +24,8 @@ class StreamTwitch {
this.title,
this.viewerCount,
this.startedAt,
this.thumbnailUrl, [
this.language = '',
this.tags = const [],
]);
this.thumbnailUrl,
);
factory StreamTwitch.fromJson(Map<String, dynamic> json) =>
_$StreamTwitchFromJson(json);
@ -46,22 +36,11 @@ class StreamsTwitch {
final List<StreamTwitch> data;
final Map<String, String> pagination;
const StreamsTwitch(this.data, this.pagination);
factory StreamsTwitch.fromJson(Map<String, dynamic> json) {
final rawPagination = json['pagination'];
final pagination = <String, String>{};
if (rawPagination is Map) {
final cursor = rawPagination['cursor'];
if (cursor is String && cursor.isNotEmpty) {
pagination['cursor'] = cursor;
}
}
return StreamsTwitch(
(json['data'] as List<dynamic>)
.map((e) => StreamTwitch.fromJson(e as Map<String, dynamic>))
.toList(),
pagination,
const StreamsTwitch(
this.data,
this.pagination,
);
}
factory StreamsTwitch.fromJson(Map<String, dynamic> json) =>
_$StreamsTwitchFromJson(json);
}

View file

@ -16,9 +16,7 @@ StreamTwitch _$StreamTwitchFromJson(Map<String, dynamic> json) => StreamTwitch(
(json['viewer_count'] as num).toInt(),
json['started_at'] 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(

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

@ -10,7 +10,12 @@ class UserTwitch {
final String displayName;
final String profileImageUrl;
const UserTwitch(this.id, this.login, this.displayName, this.profileImageUrl);
const UserTwitch(
this.id,
this.login,
this.displayName,
this.profileImageUrl,
);
factory UserTwitch.fromJson(Map<String, dynamic> json) =>
_$UserTwitchFromJson(json);
@ -22,7 +27,11 @@ class UserBlockedTwitch {
final String userLogin;
final String displayName;
const UserBlockedTwitch(this.userId, this.userLogin, this.displayName);
const UserBlockedTwitch(
this.userId,
this.userLogin,
this.displayName,
);
factory UserBlockedTwitch.fromJson(Map<String, dynamic> json) =>
_$UserBlockedTwitchFromJson(json);

View file

@ -11,7 +11,7 @@ UserTwitch _$UserTwitchFromJson(Map<String, dynamic> json) => UserTwitch(
json['login'] as String,
json['display_name'] as String,
json['profile_image_url'] as String,
);
);
UserBlockedTwitch _$UserBlockedTwitchFromJson(Map<String, dynamic> json) =>
UserBlockedTwitch(

View file

@ -3,30 +3,30 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/apis/bttv_api.dart';
import 'package:frosty/apis/ffz_api.dart';
import 'package:frosty/apis/seventv_api.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/screens/channel/chat/chat.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_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/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/video.dart';
import 'package:frosty/screens/channel/video/video_bar.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/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/theme.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/blurred_container.dart';
import 'package:frosty/widgets/draggable_divider.dart';
import 'package:frosty/widgets/frosty_notification.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/widgets/app_bar.dart';
import 'package:frosty/widgets/notification.dart';
import 'package:provider/provider.dart';
import 'package:simple_pip_mode/actions/pip_actions_layout.dart';
import 'package:simple_pip_mode/pip_widget.dart';
/// Creates a widget that shows the video stream (if live) and chat of the given user.
class VideoChat extends StatefulWidget {
static const routeName = 'VideoChat';
final String userId;
final String userName;
final String userLogin;
@ -42,293 +42,59 @@ class VideoChat extends StatefulWidget {
State<VideoChat> createState() => _VideoChatState();
}
class _VideoChatState extends State<VideoChat>
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
class _VideoChatState extends State<VideoChat> {
final _videoKey = GlobalKey();
final _chatKey = GlobalKey();
// PiP drag state — ValueNotifier avoids full widget rebuilds during drag.
final _pipDragDistance = ValueNotifier<double>(0);
bool _isPipDragging = false;
bool _isInPipTriggerZone = false;
// Divider drag state for synchronizing animation
bool _isDividerDragging = false;
// Essential constants for good UX balance
static const double _pipTriggerDistance = 80;
static const double _pipMaxDragDistance = 150;
// Animation controller for smooth spring-back
late AnimationController _animationController;
late Animation<double> _springBackAnimation;
bool? _isImmersiveSystemUiEnabled;
late final ChatTabsStore _chatTabsStore = ChatTabsStore(
twitchApi: context.twitchApi,
bttvApi: context.bttvApi,
ffzApi: context.ffzApi,
sevenTVApi: context.sevenTVApi,
authStore: context.authStore,
settingsStore: context.settingsStore,
globalAssetsStore: context.globalAssetsStore,
primaryChannelId: widget.userId,
primaryChannelLogin: widget.userLogin,
primaryDisplayName: widget.userName,
);
late final VideoPlayerInterface _videoStore =
context.settingsStore.useNativePlayer
? NativeVideoStore(
userLogin: widget.userLogin,
userId: widget.userId,
late final ChatStore _chatStore = ChatStore(
twitchApi: context.read<TwitchApi>(),
channelName: widget.userLogin,
channelId: widget.userId,
displayName: widget.userName,
twitchApi: context.twitchApi,
twitchGqlApi: context.twitchGqlApi,
authStore: context.authStore,
settingsStore: context.settingsStore,
)
: VideoStore(
auth: context.read<AuthStore>(),
settings: context.read<SettingsStore>(),
chatDetailsStore: ChatDetailsStore(
twitchApi: context.read<TwitchApi>(),
channelName: widget.userLogin,
),
assetsStore: ChatAssetsStore(
twitchApi: context.read<TwitchApi>(),
ffzApi: context.read<FFZApi>(),
bttvApi: context.read<BTTVApi>(),
sevenTVApi: context.read<SevenTVApi>(),
),
);
late final VideoStore _videoStore = VideoStore(
userLogin: widget.userLogin,
userId: widget.userId,
twitchApi: context.twitchApi,
authStore: context.authStore,
settingsStore: context.settingsStore,
twitchApi: context.read<TwitchApi>(),
authStore: context.read<AuthStore>(),
settingsStore: context.read<SettingsStore>(),
);
@override
void initState() {
super.initState();
// Initialize animation controller for smooth drag interactions
_animationController = AnimationController(
duration: const Duration(milliseconds: 250),
vsync: this,
);
// Spring-back animation with smooth easing
_springBackAnimation = Tween<double>(begin: 0, end: 0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
);
// Drive the ValueNotifier from the spring-back animation so the
// AnimatedBuilder rebuilds without a full setState.
_animationController.addListener(() {
_pipDragDistance.value = _springBackAnimation.value;
});
// Register as observer for app lifecycle events
WidgetsBinding.instance.addObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.resumed) {
_videoStore.handleAppResume();
}
}
void _handlePipDragStart(DragStartDetails details) {
if (_videoStore.isInPipMode || _videoStore.paused || _videoStore.loading) {
return;
}
_animationController.stop();
_pipDragDistance.value = 0;
_isInPipTriggerZone = false;
setState(() {
_isPipDragging = true;
});
}
void _handlePipDragUpdate(DragUpdateDetails details) {
if (!_isPipDragging ||
_videoStore.isInPipMode ||
_videoStore.paused ||
_videoStore.loading) {
return;
}
// Update the ValueNotifier directly — only the ListenableBuilder rebuilds,
// not the entire channel scaffold.
_pipDragDistance.value =
(_pipDragDistance.value + details.delta.dy).clamp(0, _pipMaxDragDistance);
final wasInTriggerZone = _isInPipTriggerZone;
_isInPipTriggerZone = _pipDragDistance.value >= _pipTriggerDistance;
if (!wasInTriggerZone && _isInPipTriggerZone) {
HapticFeedback.mediumImpact();
} else if (wasInTriggerZone && !_isInPipTriggerZone) {
HapticFeedback.lightImpact();
}
}
void _handlePipDragEnd(DragEndDetails details) {
if (!_isPipDragging ||
_videoStore.isInPipMode ||
_videoStore.paused ||
_videoStore.loading) {
return;
}
final velocity = details.velocity.pixelsPerSecond.dy;
final shouldTriggerPip =
_pipDragDistance.value >= _pipTriggerDistance || velocity > 600;
if (shouldTriggerPip) {
HapticFeedback.mediumImpact();
_videoStore.requestPictureInPicture();
_resetDragState();
} else {
_animateSpringBack();
}
}
void _handlePipDragCancel() {
if (!_isPipDragging) return;
_animateSpringBack();
}
void _resetDragState() {
_pipDragDistance.value = 0;
_isInPipTriggerZone = false;
setState(() {
_isPipDragging = false;
});
}
void _animateSpringBack() {
_springBackAnimation =
Tween<double>(begin: _pipDragDistance.value, end: 0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
);
_animationController.reset();
_animationController.forward().then((_) {
_resetDragState();
});
}
void _scheduleSystemUiModeSync({required bool immersive}) {
if (_isImmersiveSystemUiEnabled == immersive) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _isImmersiveSystemUiEnabled == immersive) return;
_isImmersiveSystemUiEnabled = immersive;
if (immersive) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
} else {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
}
});
}
/// Wraps a video widget with PiP swipe-down gesture handling.
///
/// Provides visual feedback (translate + scale), haptic feedback,
/// and an instructional overlay during the drag gesture.
Widget _buildPipGestureWrapper({required Widget child, double? aspectRatio}) {
final wrappedChild = aspectRatio != null
? AspectRatio(aspectRatio: aspectRatio, child: child)
: child;
// Listen to the ValueNotifier (drag) and AnimationController (spring-back)
// so only this subtree rebuilds during the gesture — not the full scaffold.
return ListenableBuilder(
listenable: _pipDragDistance,
child: wrappedChild,
builder: (context, animatedChild) {
final currentDragDistance = _pipDragDistance.value;
final scaleFactor =
1.0 - (currentDragDistance / _pipMaxDragDistance * 0.1);
return Transform.translate(
offset: Offset(0, currentDragDistance),
child: Transform.scale(
scale: scaleFactor.clamp(0.9, 1.0),
child: Stack(
children: [
GestureDetector(
onPanStart: _handlePipDragStart,
onPanUpdate: _handlePipDragUpdate,
onPanEnd: _handlePipDragEnd,
onPanCancel: _handlePipDragCancel,
child: animatedChild,
),
if (!_videoStore.isInPipMode)
Positioned.fill(
child: IgnorePointer(
ignoring:
!(_isPipDragging && currentDragDistance > 0),
child: AnimatedOpacity(
opacity:
(_isPipDragging && currentDragDistance > 0)
? 1.0
: 0.0,
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
child: Container(
color: Colors.black.withValues(alpha: 0.4),
child: const Center(
child: Text(
'Swipe down to enter picture-in-picture',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
),
),
),
),
],
),
),
);
},
);
}
/// Convenience getter for the currently active chat store.
ChatStore get _chatStore => _chatTabsStore.activeChatStore;
@override
Widget build(BuildContext context) {
final settingsStore = _chatTabsStore.settingsStore;
final orientation = MediaQuery.of(context).orientation;
final settingsStore = _chatStore.settings;
final appBar = FrostyAppBar(
title: Text(
getReadableName(_chatStore.displayName, _chatStore.channelName),
),
);
final player = GestureDetector(
onLongPress: _videoStore.handleToggleOverlay,
child: _videoStore is NativeVideoStore
? NativeVideo(
// GlobalKey so the native platform view is *reparented* (not
// rebuilt) across the portrait↔landscape layout swap — without
// it, rotating destroys and recreates the player, forcing a
// reload. Mirrors the WebView Video below.
child: Video(
key: _videoKey,
nativeVideoStore: _videoStore,
)
: Video(
key: _videoKey,
videoStore: _videoStore as VideoStore,
videoStore: _videoStore,
),
);
final overlay = GestureDetector(
onLongPress: _videoStore.handleToggleOverlay,
onDoubleTap: context.isLandscape
onDoubleTap: orientation == Orientation.landscape
? () => settingsStore.fullScreen = !settingsStore.fullScreen
: null,
onTap: () {
@ -336,7 +102,7 @@ class _VideoChatState extends State<VideoChat>
_chatStore.assetsStore.showEmoteMenu = false;
} else {
if (_chatStore.textFieldFocusNode.hasFocus) {
_chatStore.unfocusInput();
_chatStore.textFieldFocusNode.unfocus();
} else {
_videoStore.handleVideoTap();
}
@ -350,9 +116,7 @@ class _VideoChatState extends State<VideoChat>
settingsStore: settingsStore,
);
if (_videoStore.paused ||
_videoStore.streamInfo == null ||
_videoStore.loading) {
if (_videoStore.paused || _videoStore.streamInfo == null) {
return videoOverlay;
}
@ -360,8 +124,11 @@ class _VideoChatState extends State<VideoChat>
opacity: _videoStore.overlayVisible ? 1.0 : 0.0,
curve: Curves.ease,
duration: const Duration(milliseconds: 200),
child: ColoredBox(
color: Colors.transparent,
child: Container(
decoration: BoxDecoration(
color: Colors.black
.withValues(alpha: settingsStore.overlayOpacity),
),
child: IgnorePointer(
ignoring: !_videoStore.overlayVisible,
child: videoOverlay,
@ -372,152 +139,106 @@ class _VideoChatState extends State<VideoChat>
),
);
final video = Stack(
final video = Observer(
builder: (context) {
if (!_videoStore.settingsStore.showOverlay) return player;
return Stack(
children: [
player,
Positioned.fill(
child: Observer(
builder: (_) => AnimatedOpacity(
opacity: _videoStore.loading ? 1.0 : 0.0,
// Appear instantly to cover native player placeholders during
// recovery; fade out smoothly when playback resumes.
duration: _videoStore.loading
? Duration.zero
: const Duration(milliseconds: 300),
curve: Curves.easeOut,
// TickerMode stops the spinner's repeating animation while
// hidden — opacity 0 skips painting but would otherwise keep
// scheduling frames for the whole video session.
child: IgnorePointer(
child: TickerMode(
enabled: _videoStore.loading,
child: const ColoredBox(
color: Colors.black,
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: Colors.white38,
strokeWidth: 2,
),
),
),
),
),
),
),
),
),
Observer(
builder: (_) => _videoStore.settingsStore.showOverlay
? overlay
: const SizedBox.shrink(),
),
overlay,
],
);
},
);
final chat = Observer(
builder: (context) {
final bool chatOnly = !settingsStore.showVideo;
final videoBarVisible = _videoStore.streamInfo != null &&
_chatStore.settings.showVideo &&
(_videoStore.paused || _videoStore.overlayVisible);
return Stack(
children: [
ChatTabs(
Chat(
key: _chatKey,
chatTabsStore: _chatTabsStore,
// In chat-only mode, account for the blurred AppBar height
listPadding: chatOnly
? EdgeInsets.only(top: context.safePaddingTop)
: null,
chatStore: _chatStore,
),
Observer(
builder: (_) => AnimatedSwitcher(
if (orientation == Orientation.portrait)
AnimatedOpacity(
opacity: videoBarVisible ? 1 : 0,
curve: Curves.ease,
duration: const Duration(milliseconds: 200),
child: IgnorePointer(
ignoring: !videoBarVisible,
child: ColoredBox(
color: Theme.of(context).scaffoldBackgroundColor,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_videoStore.streamInfo != null)
VideoBar(
streamInfo: _videoStore.streamInfo!,
tappableCategory: false,
),
const Divider(),
],
),
),
),
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: _chatStore.notification != null
? Align(
alignment: Alignment.topCenter,
child: Padding(
padding: EdgeInsets.only(
top: chatOnly ? context.safePaddingTop : 0,
),
alignment: _chatStore.settings.chatNotificationsOnBottom
? Alignment.bottomCenter
: Alignment.topCenter,
child: FrostyNotification(
message: _chatStore.notification!,
onDismissed: _chatStore.clearNotification,
),
showPasteButton:
_chatStore.notification!.contains('copied'),
onButtonPressed: () async {
// Paste clipboard text into the text controller.
final data =
await Clipboard.getData(Clipboard.kTextPlain);
if (data != null) {
_chatStore.textController.text = data.text!;
}
_chatStore.updateNotification('');
},
),
)
: null,
),
),
],
);
},
);
final videoChat = Observer(
builder: (context) {
_scheduleSystemUiModeSync(
immersive:
context.isLandscape && !settingsStore.landscapeForceVerticalChat,
);
// Build a blurred AppBar when in chat-only mode (no video)
PreferredSizeWidget? chatOnlyBlurredAppBar;
if (!settingsStore.showVideo) {
final streamInfo = _videoStore.streamInfo;
chatOnlyBlurredAppBar = AppBar(
centerTitle: false,
elevation: 0,
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
systemOverlayStyle: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness:
context.theme.brightness == Brightness.dark
? Brightness.light
: Brightness.dark,
),
title: StreamInfoBar(
streamInfo: streamInfo,
offlineChannelInfo: _videoStore.offlineChannelInfo,
displayName: _chatStore.displayName,
isCompact: true,
isOffline: streamInfo == null,
isInSharedChatMode: _chatStore.isInSharedChatMode,
showTextShadows: false,
),
flexibleSpace: BlurredContainer(
gradientDirection: GradientDirection.up,
child: const SizedBox.expand(),
),
);
}
return Scaffold(
extendBody: true,
extendBodyBehindAppBar: true,
appBar: chatOnlyBlurredAppBar,
final videoChat = Scaffold(
body: Observer(
builder: (context) {
if (context.isLandscape &&
if (orientation == Orientation.landscape &&
!settingsStore.landscapeForceVerticalChat) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
final landscapeChat = AnimatedContainer(
curve: Curves.ease,
duration: _isDividerDragging
? Duration.zero
: const Duration(milliseconds: 200),
duration: const Duration(milliseconds: 200),
width: _chatStore.expandChat
? context.screenWidth / 2
: context.screenWidth * settingsStore.chatWidth,
color: settingsStore.fullScreen
? MediaQuery.of(context).size.width / 2
: MediaQuery.of(context).size.width *
_chatStore.settings.chatWidth,
color: _chatStore.settings.fullScreen
? Colors.black.withValues(
alpha: settingsStore.fullScreenChatOverlayOpacity,
alpha: _chatStore.settings.fullScreenChatOverlayOpacity,
)
: context.scaffoldColor,
: Theme.of(context).scaffoldBackgroundColor,
child: chat,
);
@ -529,8 +250,12 @@ class _VideoChatState extends State<VideoChat>
colorSchemeSeed: Color(settingsStore.accentColor),
).dark,
child: DefaultTextStyle(
style: context.defaultTextStyle.copyWith(
color: context.frostyColors.overlayOnSurface,
style: DefaultTextStyle.of(context).style.copyWith(
color: context
.watch<FrostyThemes>()
.dark
.colorScheme
.onSurface,
),
child: landscapeChat,
),
@ -540,55 +265,29 @@ class _VideoChatState extends State<VideoChat>
return ColoredBox(
color: settingsStore.showVideo
? Colors.black
: context.scaffoldColor,
: Theme.of(context).scaffoldBackgroundColor,
child: SafeArea(
bottom: false,
left: (settingsStore.landscapeCutout ==
LandscapeCutoutType.both ||
settingsStore.landscapeCutout ==
LandscapeCutoutType.left)
? false
: true,
right: (settingsStore.landscapeCutout ==
LandscapeCutoutType.both ||
settingsStore.landscapeCutout ==
LandscapeCutoutType.right)
? false
: true,
child: settingsStore.showVideo
? settingsStore.fullScreen
? Stack(
children: [
_buildPipGestureWrapper(child: player),
player,
if (settingsStore.showOverlay)
LayoutBuilder(
builder: (context, constraints) {
final totalWidth = constraints.maxWidth;
final chatWidth = _chatStore.expandChat
? 0.5
: settingsStore.chatWidth;
final draggableDivider = Observer(
builder: (_) => DraggableDivider(
currentWidth: chatWidth,
maxWidth: 0.6,
isResizableOnLeft: settingsStore
.landscapeChatLeftSide,
showHandle:
_videoStore.overlayVisible &&
settingsStore
.fullScreenChatOverlay,
onDragStart: () {
setState(() {
_isDividerDragging = true;
});
},
onDrag: (newWidth) {
if (!_chatStore.expandChat) {
settingsStore.chatWidth =
newWidth;
}
},
onDragEnd: () {
setState(() {
_isDividerDragging = false;
});
},
),
);
return Stack(
children: [
Row(
children:
settingsStore
.landscapeChatLeftSide
children: settingsStore.landscapeChatLeftSide
? [
overlayChat,
Expanded(child: overlay),
@ -598,198 +297,54 @@ class _VideoChatState extends State<VideoChat>
overlayChat,
],
),
if (settingsStore
.fullScreenChatOverlay)
Positioned(
top: 0,
bottom: 0,
left:
settingsStore
.landscapeChatLeftSide
? (totalWidth * chatWidth) -
12
: null,
right:
!settingsStore
.landscapeChatLeftSide
? (totalWidth * chatWidth) -
12
: null,
child: draggableDivider,
),
],
);
},
),
],
)
: SafeArea(
bottom: false,
left:
settingsStore.landscapeCutout !=
LandscapeCutoutType.left &&
settingsStore.landscapeCutout !=
LandscapeCutoutType.both,
right:
settingsStore.landscapeCutout !=
LandscapeCutoutType.right &&
settingsStore.landscapeCutout !=
LandscapeCutoutType.both,
child: LayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.maxWidth;
final chatWidth = _chatStore.expandChat
? 0.5
: settingsStore.chatWidth;
// Create the landscape chat container with proper styling
final chatContainer = AnimatedContainer(
curve: Curves.ease,
duration: _isDividerDragging
? Duration.zero
: const Duration(milliseconds: 200),
width: availableWidth * chatWidth,
color: settingsStore.fullScreen
? Colors.black.withValues(
alpha: settingsStore
.fullScreenChatOverlayOpacity,
)
: context.scaffoldColor,
child: chat,
);
final draggableDivider = Observer(
builder: (_) => DraggableDivider(
currentWidth: chatWidth,
maxWidth: 0.6,
isResizableOnLeft:
settingsStore.landscapeChatLeftSide,
showHandle: _videoStore.overlayVisible,
onDragStart: () {
setState(() {
_isDividerDragging = true;
});
},
onDrag: (newWidth) {
if (!_chatStore.expandChat) {
settingsStore.chatWidth = newWidth;
}
},
onDragEnd: () {
setState(() {
_isDividerDragging = false;
});
},
),
);
return Stack(
children: [
Row(
children:
settingsStore
.landscapeChatLeftSide
: Row(
children: settingsStore.landscapeChatLeftSide
? [
chatContainer,
Expanded(
child:
_buildPipGestureWrapper(
child: video,
),
),
landscapeChat,
const VerticalDivider(),
Expanded(child: video),
]
: [
Expanded(
child:
_buildPipGestureWrapper(
child: video,
),
),
chatContainer,
Expanded(child: video),
const VerticalDivider(),
landscapeChat,
],
),
Positioned(
top: 0,
bottom: 0,
left:
settingsStore
.landscapeChatLeftSide
? (availableWidth * chatWidth) -
12
: null,
right:
!settingsStore
.landscapeChatLeftSide
? (availableWidth * chatWidth) -
12
: null,
child: draggableDivider,
),
],
);
},
),
)
: SafeArea(child: chat),
: Column(
children: [appBar, Expanded(child: chat)],
),
),
);
}
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
return SafeArea(
// Keep chat-only scrolling under the blurred app bar, but
// ensure video does not bleed into the system status bar.
top: settingsStore.showVideo,
bottom: false,
child: Stack(
child: Column(
children: [
Column(
children: [
if (settingsStore.showVideo) ...[
AspectRatio(
aspectRatio: 16 / 9,
child: SizedBox(),
),
ChannelInfoHeader(
videoStore: _videoStore,
authStore: context.authStore,
userId: widget.userId,
displayName: widget.userName,
isInSharedChatMode:
_chatStore.isInSharedChatMode,
),
if (!settingsStore.showVideo)
appBar
else ...[
AspectRatio(aspectRatio: 16 / 9, child: video),
const Divider(),
],
Expanded(child: chat),
],
),
if (settingsStore.showVideo)
Positioned(
top: 0,
left: 0,
right: 0,
child: _buildPipGestureWrapper(
child: video,
aspectRatio: 16 / 9,
),
),
],
),
);
},
),
);
},
);
// If on Android, use PiPSwitcher to enable PiP functionality.
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(
pipLayout: PipActionsLayout.mediaOnlyPause,
onPipAction: (_) => _videoStore.handlePausePlay(),
onPipEntered: () => notifyPip(true),
onPipExited: () => notifyPip(false),
pipChild: player,
child: videoChat,
);
@ -800,16 +355,10 @@ class _VideoChatState extends State<VideoChat>
@override
void dispose() {
// Remove observer for app lifecycle events
WidgetsBinding.instance.removeObserver(this);
_chatTabsStore.dispose();
_chatStore.dispose();
_videoStore.dispose();
_animationController.dispose();
_pipDragDistance.dispose();
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,

View file

@ -1,42 +1,18 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.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/recent_emotes_panel.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_bottom_bar.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/frosty_page_view.dart';
import 'package:frosty/widgets/frosty_scrollbar.dart';
/// ~2x default to keep richer message widgets (emotes, badges, replies) built
/// ahead of fast scroll-back without holding the entire history.
const _chatCacheExtent = ScrollCacheExtent.pixels(500.0);
import 'package:frosty/widgets/page_view.dart';
class Chat extends StatelessWidget {
final ChatStore chatStore;
final EdgeInsetsGeometry? listPadding;
/// Callback to add a new chat tab.
/// Passes this to ChatBottomBar for the ChatDetails menu.
final VoidCallback onAddChat;
/// When set, the widget operates in merged mode: messages come from all tabs
/// and the active tab determines the send target.
final ChatTabsStore? chatTabsStore;
const Chat({
super.key,
required this.chatStore,
this.listPadding,
required this.onAddChat,
this.chatTabsStore,
});
bool get _isMerged => chatTabsStore != null;
const Chat({super.key, required this.chatStore});
@override
Widget build(BuildContext context) {
@ -45,312 +21,132 @@ class Chat extends StatelessWidget {
return Column(
children: [
Expanded(
child: Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
// Wrap only the message list with GestureDetector
// so taps on ChatBottomBar don't trigger unfocus
GestureDetector(
behavior: HitTestBehavior.opaque,
child: GestureDetector(
onTap: () {
if (chatStore.assetsStore.showEmoteMenu) {
chatStore.assetsStore.showEmoteMenu = false;
} else if (chatStore.textFieldFocusNode.hasFocus) {
chatStore.unfocusInput();
chatStore.textFieldFocusNode.unfocus();
}
},
child: MediaQuery(
child: Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: chatStore.settings.messageScale.textScaler,
textScaler:
TextScaler.linear(chatStore.settings.messageScale),
),
child: DefaultTextStyle(
style: context.defaultTextStyle.copyWith(
fontSize: chatStore.settings.fontSize,
),
child: Builder(
builder: (context) {
// Don't add bottom padding in horizontal landscape
// (immersive mode with home indicator on side).
// landscapeForceVerticalChat uses portrait layout
// with normal system UI, so still needs padding.
final isHorizontalLandscape =
context.isLandscape &&
!chatStore.settings.landscapeForceVerticalChat;
final bottomPadding =
chatStore.assetsStore.showEmoteMenu ||
isHorizontalLandscape
? 0.0
: MediaQuery.of(context).padding.bottom;
final scrollController = _isMerged
? chatTabsStore!.mergedScrollController
: chatStore.scrollController;
// Freeze the message list while a finger rests on a
// message so a long-press target can't scroll out
// from under it (which would dispose its recognizer
// mid-press). The store resumes flowing as soon as
// the pointer moves like a scroll, so scrolling is
// never interrupted — only a stationary hold pauses.
void onDown(PointerDownEvent e) => _isMerged
? chatTabsStore!.onMessageListPointerDown(
e.pointer,
e.position,
)
: chatStore.onMessageListPointerDown(
e.pointer,
e.position,
);
void onMove(PointerMoveEvent e) => _isMerged
? chatTabsStore!.onMessageListPointerMove(
e.pointer,
e.position,
)
: chatStore.onMessageListPointerMove(
e.pointer,
e.position,
);
void onUp(int pointer) => _isMerged
? chatTabsStore!.onMessageListPointerUp(pointer)
: chatStore.onMessageListPointerUp(pointer);
return Listener(
onPointerDown: onDown,
onPointerMove: onMove,
onPointerUp: (e) => onUp(e.pointer),
onPointerCancel: (e) => onUp(e.pointer),
child: FrostyScrollbar(
controller: scrollController,
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top,
bottom:
chatStore.bottomBarHeight + bottomPadding,
),
style: DefaultTextStyle.of(context)
.style
.copyWith(fontSize: chatStore.settings.fontSize),
child: Scrollbar(
controller: chatStore.scrollController,
child: Observer(
builder: (context) {
return _isMerged
? _buildMergedList(
scrollController,
bottomPadding,
)
: _buildNormalList(
scrollController,
bottomPadding,
);
},
),
),
);
},
),
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: ChatBottomBar(
chatStore: chatStore,
onAddChat: onAddChat,
channelDisplayName: _isMerged
? getReadableName(
chatTabsStore!.activeTab.displayName,
chatTabsStore!.activeTab.channelLogin,
)
: null,
),
),
_buildResumeScrollButton(),
],
),
),
_buildEmoteMenu(context),
],
);
},
);
}
Widget _buildNormalList(
ScrollController scrollController,
double bottomPadding,
) {
return ListView.builder(
reverse: true,
padding: (listPadding ?? EdgeInsets.zero).add(
EdgeInsets.only(bottom: chatStore.bottomBarHeight + bottomPadding),
),
padding: EdgeInsets.zero,
addAutomaticKeepAlives: false,
scrollCacheExtent: _chatCacheExtent,
controller: scrollController,
controller: chatStore.scrollController,
itemCount: chatStore.renderMessages.length,
itemBuilder: (context, index) {
final ircMessage = chatStore
.renderMessages[chatStore.renderMessages.length - 1 - index];
// Key by message identity so the list reconciles by message rather
// than by slot. This keeps each message's element (and its gesture
// recognizers) bound to the same message when new messages arrive
// mid-interaction, fixing long-press landing on the wrong message.
return ChatMessage(
key: ObjectKey(ircMessage),
ircMessage: ircMessage,
itemBuilder: (context, index) => ChatMessage(
ircMessage: chatStore.renderMessages[
chatStore.renderMessages.length -
1 -
index],
chatStore: chatStore,
);
},
);
}
Widget _buildMergedList(
ScrollController scrollController,
double bottomPadding,
) {
final mergedMessages = chatTabsStore!.mergedMessages;
final channelIdToUserTwitch = chatTabsStore!.mergedChannelIdToUserTwitch;
final currentChannelId = chatTabsStore!.activeTab.channelId;
return ListView.builder(
reverse: true,
padding: (listPadding ?? EdgeInsets.zero).add(
EdgeInsets.only(bottom: chatStore.bottomBarHeight + bottomPadding),
),
addAutomaticKeepAlives: false,
scrollCacheExtent: _chatCacheExtent,
controller: scrollController,
itemCount: mergedMessages.length,
itemBuilder: (context, index) {
final merged = mergedMessages[mergedMessages.length - 1 - index];
return ChatMessage(
key: ObjectKey(merged.ircMessage),
ircMessage: merged.ircMessage,
chatStore: merged.chatStore,
inputChatStore: chatStore,
onActivateSourceTab: () {
final tabs = chatTabsStore!.tabs;
final idx = tabs.indexWhere(
(t) => t.channelId == merged.chatStore.channelId,
);
if (idx != -1 && idx != chatTabsStore!.activeTabIndex) {
chatTabsStore!.setActiveTab(idx, silent: true);
}
},
overrideChannelIdToUserTwitch: channelIdToUserTwitch,
overrideCurrentChannelId: currentChannelId,
);
},
);
}
Widget _buildResumeScrollButton() {
return Builder(
builder: (context) {
final isHorizontalLandscape =
context.isLandscape &&
!chatStore.settings.landscapeForceVerticalChat;
return AnimatedPadding(
duration: const Duration(milliseconds: 200),
padding: EdgeInsets.only(
left: 4,
top: 4,
right: 4,
bottom:
chatStore.bottomBarHeight +
(chatStore.assetsStore.showEmoteMenu || isHorizontalLandscape
? 0
: MediaQuery.of(context).padding.bottom),
),
),
),
),
Padding(
padding: const EdgeInsets.all(4),
child: Observer(
builder: (_) {
final isAutoScrolling = _isMerged
? chatTabsStore!.mergedAutoScroll
: chatStore.autoScroll;
final bufferCount = _isMerged
? chatTabsStore!.mergedBufferCount
: chatStore.messageBuffer.length;
final onResume = _isMerged
? chatTabsStore!.resumeMergedScroll
: chatStore.resumeScroll;
return AnimatedSwitcher(
builder: (_) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: isAutoScrolling
child: chatStore.autoScroll
? null
: ElevatedButton.icon(
onPressed: onResume,
icon: const Icon(Icons.arrow_downward_rounded),
onPressed: chatStore.resumeScroll,
icon:
const Icon(Icons.arrow_downward_rounded),
label: Text(
bufferCount > 0
? '$bufferCount new ${bufferCount == 1 ? 'message' : 'messages'}'
chatStore.messageBuffer.isNotEmpty
? '${chatStore.messageBuffer.length} new ${chatStore.messageBuffer.length == 1 ? 'message' : 'messages'}'
: 'Resume scroll',
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
fontFeatures: [
FontFeature.tabularFigures(),
],
),
),
),
);
},
),
);
},
);
}
),
),
],
),
),
),
if (chatStore.settings.showBottomBar)
ChatBottomBar(chatStore: chatStore),
PopScope(
canPop: Platform.isIOS,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
Widget _buildEmoteMenu(BuildContext context) {
final menuHeight = context.screenHeight / (context.isPortrait ? 3 : 2);
return AnimatedContainer(
// If pressing the back button on Android while the emote menu
// is open, close it instead of going back to the streams list.
if (chatStore.assetsStore.showEmoteMenu) {
chatStore.assetsStore.showEmoteMenu = false;
} else {
Navigator.of(context).pop();
}
},
child: AnimatedContainer(
curve: Curves.ease,
duration: const Duration(milliseconds: 200),
height: chatStore.assetsStore.showEmoteMenu ? menuHeight : 0,
height: chatStore.assetsStore.showEmoteMenu
? MediaQuery.of(context).size.height /
(MediaQuery.of(context).orientation ==
Orientation.portrait
? 3
: 2)
: 0,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 100),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: chatStore.assetsStore.showEmoteMenu
? ClipRect(
// Lay the content out at its full target height regardless of
// the animated container's current height, then clip — the
// Column would otherwise overflow (RenderFlex error) as the
// height tweens toward zero and can no longer fit the Divider.
child: OverflowBox(
minHeight: 0,
maxHeight: menuHeight,
alignment: Alignment.topCenter,
child: Column(
? Column(
children: [
const Divider(),
Expanded(
child: FrostyPageView(
headers: [
'Recent',
if (chatStore.settings.showTwitchEmotes) 'Twitch',
if (chatStore.settings.showTwitchEmotes)
'Twitch',
if (chatStore.settings.show7TVEmotes) '7TV',
if (chatStore.settings.showBTTVEmotes) 'BTTV',
if (chatStore.settings.showFFZEmotes) 'FFZ',
],
tabActions: {
if (chatStore.assetsStore.recentEmotes.isNotEmpty)
0: IconButton(
onPressed: () {
chatStore.assetsStore.recentEmotes.clear();
chatStore.updateNotification(
'Recent emotes cleared',
);
},
icon: const Icon(Icons.delete_outline_rounded),
tooltip: 'Clear recent emotes',
iconSize: 20,
),
},
children: [
RecentEmotesPanel(chatStore: chatStore),
RecentEmotesPanel(
chatStore: chatStore,
),
if (chatStore.settings.showTwitchEmotes)
EmoteMenuPanel(
chatStore: chatStore,
twitchEmotes: chatStore
.assetsStore
.userEmoteSectionToEmotes,
.assetsStore.userEmoteSectionToEmotes,
),
...[
if (chatStore.settings.show7TVEmotes)
@ -369,11 +165,14 @@ class Chat extends StatelessWidget {
),
),
],
),
),
)
: null,
),
),
),
],
);
},
);
}
}

View file

@ -1,20 +1,12 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/main.dart';
import 'package:frosty/models/irc.dart';
import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
import 'package:frosty/screens/channel/chat/details/chat_modes.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/settings/settings.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:frosty/widgets/animated_scroll_border.dart';
import 'package:frosty/widgets/frosty_scrollbar.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:intl/intl.dart';
@ -23,16 +15,11 @@ class ChatDetails extends StatefulWidget {
final ChatStore chatStore;
final String userLogin;
/// Callback to add a new chat tab.
/// Shows an "Add chat" option in the menu.
final VoidCallback onAddChat;
const ChatDetails({
super.key,
required this.chatDetailsStore,
required this.chatStore,
required this.userLogin,
required this.onAddChat,
});
@override
@ -42,9 +29,6 @@ class ChatDetails extends StatefulWidget {
class _ChatDetailsState extends State<ChatDetails> {
late final _scrollController = ScrollController();
bool _isRefreshingAssets = false;
bool _showRefreshSuccess = false;
String formatDuration(Duration duration) {
if (duration.inMinutes < 60) {
return '${duration.inMinutes} ${Intl.plural(duration.inMinutes, one: 'minute', other: 'minutes')}';
@ -59,11 +43,11 @@ class _ChatDetailsState extends State<ChatDetails> {
final seconds = duration.inSeconds.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m';
return '${hours}h ${minutes}m left';
} else if (minutes > 0) {
return '${minutes}m ${seconds}s';
return '${minutes}m ${seconds}s left';
} else {
return '${seconds}s';
return '${seconds}s left';
}
}
@ -87,7 +71,7 @@ class _ChatDetailsState extends State<ChatDetails> {
Duration(hours: 12),
];
return showModalBottomSheetWithProperFocus(
return showModalBottomSheet(
context: context,
builder: (context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -107,26 +91,12 @@ class _ChatDetailsState extends State<ChatDetails> {
builder: (context) {
return ListTile(
leading: const Icon(Icons.close_rounded),
title: Text.rich(
TextSpan(
text: 'Cancel ',
children: [
TextSpan(
text: formatTimeLeft(
widget.chatStore.timeRemaining,
),
style: TextStyle(
color: Theme.of(context)
.textTheme
.bodyMedium
?.color
?.withValues(alpha: 0.6),
title: Text(
'Turn off (${formatTimeLeft(widget.chatStore.timeRemaining)})',
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
),
],
),
),
onTap: () {
widget.chatStore.cancelSleepTimer();
@ -158,207 +128,49 @@ class _ChatDetailsState extends State<ChatDetails> {
);
}
/// Converts a hex color string to a color name if it matches one of our predefined chat colors.
/// Returns null if no match is found.
String? _hexToColorName(String hexColor) {
// Remove # if present
final cleanHex = hexColor.startsWith('#')
? hexColor.substring(1)
: hexColor;
// Convert to uppercase for comparison
final upperHex = cleanHex.toUpperCase();
// Find matching color name
for (final entry in chatColorValues.entries) {
final colorValue = entry.value.toARGB32().toRadixString(16).toUpperCase();
// Remove alpha channel (first 2 characters) for comparison
if (colorValue.length >= 6) {
final colorHex = colorValue.substring(2);
if (colorHex == upperHex) {
return entry.key;
}
}
}
return null;
}
Future<void> _showChatColorPicker(BuildContext context) async {
final selectedColor = await _getCurrentUserColor();
if (!context.mounted) return;
showModalBottomSheetWithProperFocus(
Future<void> _showClearDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => SizedBox(
height: MediaQuery.of(context).size.height * 0.5,
child: _ChatColorPickerModal(
initialColor: selectedColor,
scrollController: _scrollController,
onColorSelected: _handleColorUpdate,
chatStore: widget.chatStore,
builder: (context) => AlertDialog.adaptive(
title: const Text('Clear recent emotes'),
content:
const Text('Are you sure you want to clear your recent emotes?'),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
widget.chatStore.assetsStore.recentEmotes.clear();
Navigator.pop(context);
},
child: const Text('Yes'),
),
],
),
);
}
Future<String?> _getCurrentUserColor() async {
try {
final currentColorHex = await widget.chatStore.twitchApi.getUserChatColor(
userId: widget.chatStore.auth.user.details!.id,
);
if (currentColorHex.isNotEmpty) {
return _hexToColorName(currentColorHex);
}
} catch (e) {
// Ignore errors when fetching current color
}
return null;
}
Future<void> _handleColorUpdate(BuildContext context, String color) async {
final success = await widget.chatStore.twitchApi.updateUserChatColor(
userId: widget.chatStore.auth.user.details!.id,
color: color,
);
if (!context.mounted) return;
Navigator.of(context).pop();
final message = success
? 'Chat color updated successfully!'
: 'Failed to update chat color. Please try again.';
_showMessage(context, message);
}
void _showMessage(BuildContext context, String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
bool _hasActiveModes() {
final roomState = widget.chatDetailsStore.roomState;
return roomState.subMode != '0' ||
roomState.followersOnly != '-1' ||
roomState.emoteOnly != '0' ||
roomState.slowMode != '0' ||
roomState.r9k != '0';
}
Widget _buildRefreshTrailingWidget() {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: _isRefreshingAssets
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
)
: _showRefreshSuccess
? Icon(
Icons.check_rounded,
color: Theme.of(context).colorScheme.primary,
key: const ValueKey('success'),
)
: const SizedBox.shrink(),
);
}
Future<void> _handleRefreshAssets() async {
setState(() => _isRefreshingAssets = true);
try {
await widget.chatStore.getAssets();
if (mounted) {
HapticFeedback.lightImpact();
setState(() {
_isRefreshingAssets = false;
_showRefreshSuccess = true;
});
// Hide success after 2 seconds
Timer(const Duration(seconds: 2), () {
if (mounted) {
setState(() => _showRefreshSuccess = false);
}
});
}
} catch (e) {
if (mounted) {
setState(() => _isRefreshingAssets = false);
}
}
}
@override
Widget build(BuildContext context) {
return Observer(
builder: (context) {
final hasActiveModes = _hasActiveModes();
final children = [
if (hasActiveModes)
ListTile(
title: SizedBox(
height: 48,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
Observer(
builder: (context) => ChatModes(
roomState: widget.chatDetailsStore.roomState,
),
),
],
),
),
),
Observer(
builder: (context) {
final showVideo = widget.chatStore.settings.showVideo;
final label = showVideo ? 'Chat only' : 'Show video';
return ListTile(
leading: Icon(
showVideo ? Icons.chat_rounded : Icons.tv_rounded,
),
title: Text(label),
onTap: () {
widget.chatStore.settings.showVideo = !showVideo;
Navigator.of(context).pop();
},
);
},
const SectionHeader(
'Chat modes',
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
isFirst: true,
),
ListTile(
leading: const Icon(Icons.sync_rounded),
title: const Text('Refresh emotes and badges'),
trailing: _buildRefreshTrailingWidget(),
enabled: !_isRefreshingAssets,
onTap: _isRefreshingAssets ? null : _handleRefreshAssets,
title: ChatModes(roomState: widget.chatDetailsStore.roomState),
),
const SectionHeader(
'More',
),
ListTile(
leading: const Icon(Icons.wifi_off_rounded),
title: const Text('Reconnect'),
onTap: () {
Navigator.of(context).pop();
widget.chatStore.updateNotification('Reconnecting to chat...');
widget.chatStore.connectToChat();
},
),
if (widget.chatStore.auth.isLoggedIn)
ListTile(
leading: const Icon(Icons.palette_rounded),
title: const Text('Username color'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showChatColorPicker(context),
),
ListTile(
leading: const Icon(Icons.people_rounded),
leading: const Icon(Icons.people_outline),
title: const Text('Chatters'),
trailing: const Icon(Icons.chevron_right),
onTap: () => showModalBottomSheetWithProperFocus(
onTap: () => showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) => GestureDetector(
@ -371,52 +183,61 @@ class _ChatDetailsState extends State<ChatDetails> {
),
),
),
ListTile(
leading: const Icon(Icons.add_comment_rounded),
title: const Text('Add chat'),
trailing: const Icon(Icons.chevron_right),
onTap: widget.onAddChat,
),
Observer(
builder: (context) {
final hasTimer = widget.chatStore.timeRemaining.inSeconds > 0;
final label = hasTimer
? formatTimeLeft(widget.chatStore.timeRemaining)
: 'Sleep timer';
return ListTile(
leading: Icon(
hasTimer ? Icons.timer_rounded : Icons.timer_rounded,
color: hasTimer
? Theme.of(context).colorScheme.primary
: null,
),
leading: const Icon(Icons.timer_outlined),
title: Text(
label,
style: hasTimer
? TextStyle(
fontFeatures: const [FontFeature.tabularFigures()],
color: Theme.of(context).colorScheme.primary,
)
: null,
'Sleep timer ${widget.chatStore.timeRemaining.inSeconds > 0 ? 'on (${formatTimeLeft(widget.chatStore.timeRemaining)})' : ''}',
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
),
trailing: hasTimer
? Icon(
Icons.chevron_right,
color: Theme.of(context).colorScheme.primary,
)
: const Icon(Icons.chevron_right),
onTap: () => _showSleepTimer(context),
);
},
),
ListTile(
leading: const Icon(Icons.settings_rounded),
leading: const Icon(Icons.delete_outline_rounded),
title: const Text('Clear recent emotes'),
onTap: () => _showClearDialog(context),
),
ListTile(
leading: const Icon(Icons.refresh_rounded),
title: const Text('Reconnect to chat'),
onTap: () {
widget.chatStore.updateNotification('Reconnecting to chat...');
widget.chatStore.connectToChat();
},
),
ListTile(
leading: const Icon(Icons.refresh_rounded),
title: const Text('Refresh badges and emotes'),
onTap: () async {
await widget.chatStore.getAssets();
widget.chatStore.updateNotification('Badges and emotes refreshed');
},
),
ListTile(
leading: const Icon(Icons.chat_outlined),
title: Observer(
builder: (context) {
return Text(
'${widget.chatStore.settings.showVideo ? 'Enter' : 'Exit'} chat-only mode',
);
},
),
onTap: () => widget.chatStore.settings.showVideo =
!widget.chatStore.settings.showVideo,
),
ListTile(
leading: const Icon(Icons.settings_outlined),
title: const Text('Settings'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: Settings.routeName),
builder: (context) =>
Settings(settingsStore: widget.chatStore.settings),
),
@ -424,8 +245,10 @@ class _ChatDetailsState extends State<ChatDetails> {
),
];
return ListView(shrinkWrap: true, primary: false, children: children);
},
return ListView(
shrinkWrap: true,
primary: false,
children: children,
);
}
@ -435,187 +258,3 @@ class _ChatDetailsState extends State<ChatDetails> {
super.dispose();
}
}
class _ChatColorPickerModal extends StatefulWidget {
const _ChatColorPickerModal({
required this.initialColor,
required this.scrollController,
required this.onColorSelected,
required this.chatStore,
});
final String? initialColor;
final ScrollController scrollController;
final Future<void> Function(BuildContext context, String color)
onColorSelected;
final ChatStore chatStore;
@override
State<_ChatColorPickerModal> createState() => _ChatColorPickerModalState();
}
class _ChatColorPickerModalState extends State<_ChatColorPickerModal> {
String? selectedColor;
@override
void initState() {
super.initState();
selectedColor = widget.initialColor;
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
_buildPreview(),
AnimatedScrollBorder(scrollController: widget.scrollController),
_buildColorList(),
_buildBottomBar(),
],
);
}
Widget _buildHeader() {
return const SectionHeader('Username color', isFirst: true);
}
Widget _buildPreview() {
if (selectedColor == null) return const SizedBox.shrink();
// Create a realistic preview using the current user's chat state and badges
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: _buildRealisticChatPreview(),
);
}
Widget _buildRealisticChatPreview() {
final userDetails = widget.chatStore.auth.user.details!;
final userState = widget.chatStore.userState;
// Create a mock IRC message with the user's current state
final mockTags = <String, String>{
'display-name': userDetails.displayName,
'color':
'#${chatColorValues[selectedColor!]!.toARGB32().toRadixString(16).substring(2)}',
'user-id': userDetails.id,
'mod': userState.mod ? '1' : '0',
'subscriber': userState.subscriber ? '1' : '0',
'id': 'preview-message',
'tmi-sent-ts': DateTime.now().millisecondsSinceEpoch.toString(),
};
// Copy any badges from the current user state
if (userState.raw != null) {
final userStateMessage = IRCMessage.fromString(userState.raw!);
if (userStateMessage.tags['badges'] != null) {
mockTags['badges'] = userStateMessage.tags['badges']!;
}
if (userStateMessage.tags['badge-info'] != null) {
mockTags['badge-info'] = userStateMessage.tags['badge-info']!;
}
}
final mockMessage = IRCMessage(
raw: '',
command: Command.privateMessage,
tags: mockTags,
user: userDetails.login,
message: 'How it will look in chat',
split: ['How', 'it', 'will', 'look', 'in', 'chat'],
action: false,
mention: false,
);
return Text.rich(
TextSpan(
children: mockMessage.generateSpan(
context,
assetsStore: widget.chatStore.assetsStore,
emoteScale: widget.chatStore.settings.emoteScale,
badgeScale: widget.chatStore.settings.badgeScale,
launchExternal: false, // Disable launching for preview
style: DefaultTextStyle.of(context).style,
),
),
);
}
Widget _buildColorList() {
return Expanded(
child: FrostyScrollbar(
controller: widget.scrollController,
child: ListView(
padding: EdgeInsets.zero,
controller: widget.scrollController,
children: chatColorNames
.map((colorName) => _buildColorTile(colorName))
.toList(),
),
),
);
}
Widget _buildColorTile(String colorName) {
final isSelected = selectedColor == colorName;
final displayName = colorName
.replaceAll('_', ' ')
.split(' ')
.map((word) => word[0].toUpperCase() + word.substring(1))
.join(' ');
// Apply the same color adjustment for consistent preview
final rawColor = chatColorValues[colorName]!;
final adjustedColor = adjustChatNameColor(context, rawColor);
return ListTile(
leading: Container(
width: 24,
height: 24,
decoration: BoxDecoration(color: adjustedColor, shape: BoxShape.circle),
),
title: Text(displayName),
trailing: isSelected
? Icon(Icons.check, color: Theme.of(context).colorScheme.primary)
: null,
onTap: () => setState(() => selectedColor = colorName),
);
}
Widget _buildBottomBar() {
return Column(
children: [
AnimatedScrollBorder(
scrollController: widget.scrollController,
position: ScrollBorderPosition.bottom,
),
SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Expanded(
child: TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: selectedColor == null
? null
: () => widget.onColorSelected(context, selectedColor!),
child: const Text('Save'),
),
),
],
),
),
),
],
);
}
}

View file

@ -12,16 +12,13 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store {
Computed<Iterable<String>>? _$filteredUsersComputed;
@override
Iterable<String> get filteredUsers =>
(_$filteredUsersComputed ??= Computed<Iterable<String>>(
() => super.filteredUsers,
name: 'ChatDetailsStoreBase.filteredUsers',
)).value;
Iterable<String> get filteredUsers => (_$filteredUsersComputed ??=
Computed<Iterable<String>>(() => super.filteredUsers,
name: 'ChatDetailsStoreBase.filteredUsers'))
.value;
late final _$roomStateAtom = Atom(
name: 'ChatDetailsStoreBase.roomState',
context: context,
);
late final _$roomStateAtom =
Atom(name: 'ChatDetailsStoreBase.roomState', context: context);
@override
ROOMSTATE get roomState {
@ -36,10 +33,8 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store {
});
}
late final _$showJumpButtonAtom = Atom(
name: 'ChatDetailsStoreBase.showJumpButton',
context: context,
);
late final _$showJumpButtonAtom =
Atom(name: 'ChatDetailsStoreBase.showJumpButton', context: context);
@override
bool get showJumpButton {
@ -54,10 +49,8 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store {
});
}
late final _$_filterTextAtom = Atom(
name: 'ChatDetailsStoreBase._filterText',
context: context,
);
late final _$_filterTextAtom =
Atom(name: 'ChatDetailsStoreBase._filterText', context: context);
String get filterText {
_$_filterTextAtom.reportRead();

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