Compare commits

..

28 commits

Author SHA1 Message Date
Tommy Chow
e73d9b255f add VOD browsing with synchronized chat replay
Implements VOD (past broadcast) browsing and chat replay that reuses
the existing live chat rendering pipeline in read-only mode.

Models & API:
- video.dart: Helix video model with duration parsing (3h26m15s → Duration)
- gql_chat_comment.dart: GQL VOD chat comment models (manual parsing)
- IRCMessage.fromGqlComment(): adapter mapping GQL comments → IRC format
  so the existing chat rendering pipeline (generateSpan, badges, emotes)
  works unchanged for VOD chat
- TwitchApi.getVideos(): Helix endpoint for user VOD list with pagination
- TwitchGqlApi.getVideoComments(): GQL persisted query using mobile
  client ID (bypasses Client-Integrity) for VOD chat comments

Architecture:
- ChatRenderContext interface: minimal abstraction over ChatStore so
  ChatMessage widget works with both live ChatStore and VodChatStore
- ChatStore now implements ChatRenderContext (one-line change)
- ChatMessage accepts ChatRenderContext instead of ChatStore, with
  type guards for live-only actions (reply, paste, name tap navigation)

VOD stores:
- VodChatStore: MobX store implementing ChatRenderContext with identical
  message batching (200ms timer, 5000 limit, 100 render limit, 20% trim),
  scroll/autoscroll, playback position tracking, seek detection
  (delta < -5 or > 10 triggers clear + re-fetch), comment buffer with
  pre-fetching when < 50 comments remain
- VodListStore: standard paginated list store for VOD browsing

VOD UI:
- VodPlayer: matches live channel layout exactly (no AppBar, SafeArea,
  16:9 WebView + expanded chat) with JS position polling every 1s +
  seeked event listener via VodPosition JS channel
- VodList: paginated VOD list with scroll-based loading
- VodCard: thumbnail card with duration overlay and relative dates
- Entry point: "Past broadcasts" in chat details drawer

Tests (21 new):
- video_test.dart: VideoTwitch/VideosTwitch parsing, duration parsing
- gql_chat_comment_test.dart: comment parsing, response error handling,
  IRCMessage.fromGqlComment adapter (tags, localEmotes, deleted user)
- twitch_gql_api_test.dart: getVideoComments mock tests
- twitch_api_test.dart: getVideos mock tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:47:14 -07:00
Tommy Chow
6695c13455 clean up native video player implementation
Extract shared stream quality key constant, add null guard to
playback token parsing, fix handleRefresh race condition and
concurrency, and replace Container with SizedBox placeholder.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:45:15 -07:00
Tommy Chow
d22c980454 fix native player freezing indefinitely on buffer stall
Handle buffering/error activity states that were silently ignored,
causing a frozen frame with no loading indicator. Add two-stage stall
recovery (seek to live edge after 8s, full HLS refresh after 16s).
Stop forcing paused state on transient API failures and restore
playback on app resume unless the user explicitly paused.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:30:36 -07:00
Tommy Chow
3dbdd3501a fix inconsistent chat styles and section header typography
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:34:14 -07:00
Tommy Chow
f9141e3bae fix vertical alignment of web session icon in profile card
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:29:05 -07:00
Tommy Chow
ad718e1bb2 refine list tile and section header typography
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:04:42 -07:00
Tommy Chow
7839dc9135 mute unfocused tab icons on home page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 23:50:32 -07:00
Tommy Chow
3b0e7f9c08 use FrostyDialog for web session info dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 23:37:32 -07:00
Tommy Chow
0a52ca3724 move web session indicator next to username with info icon
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 23:20:29 -07:00
Tommy Chow
9c1be7e121 clean up settings: move keep-awake to chat, improve labels and section headers
- move keepScreenAwake from video to chat settings (store + UI)
- mute section headers (lighter weight, smaller, reduced opacity)
- add breathing room between app bar and first setting
- improve labels across general, video, and chat settings
- reorder store fields to logical grouping with organized reset

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 23:13:44 -07:00
Tommy Chow
8b8b190316 fix video loading spinner not covering full player area
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
9529438a3f add disposed guards to error path in _initPlayer
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
d01ee26fca revert iOS latency measurement to wall-clock approach
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
e7c726daeb guard async callbacks against post-dispose use
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
144c5e3f1f fix stream info mutations outside MobX action context
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
cb79dfd233 update fork with auto quality fix and LiveConfiguration on quality switch
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
97d371c0e1 update fork with media3 1.9.2
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
adb166ff92 hide iOS player placeholder logo via black background
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
a69eb5ddfa update fork with tuned live playback and reverted iOS seek
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
649710c758 show loading spinner instead of flashing offline/play button on video load
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
8d5ccb699e enable native video player on Android
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
d048875490 add web session indicator to profile card, clean up cookie extraction
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
f27de6dc3d remove auto seek-to-live-edge to fix random pausing
The seekToLiveEdge call was firing too aggressively — on initial load
latency reports inflated values before the player stabilizes, and
each seek interrupts playback. The refresh button handles manual
recovery if the viewer falls behind.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
b2039e1cd8 update fork dependency to fix empty switch case build error
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
6e5da0bae0 optimize native player with immediate latency, auto seek-to-live-edge, and app resume recovery
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
418c51ce6d handle offline channels gracefully in native video player
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
1779520db8 use forked plugin for seamless quality switching, add latency tracking and cookie auth
Switch to forked better_native_video_player that uses preferredPeakBitRate
instead of replaceCurrentItem for seamless quality transitions without
black flash. Fork also adds configureForLivePlayback and getLatencyToLive
APIs for low-latency tuning and HLS program date-time based latency.

Add latency polling (10s interval) with auto-sync chat delay support.
Only update syncedChatDelay on >2s drift to avoid restarting countdown.

Add cookie extraction from WKWebView login session for ad-free GQL
playback. The auth-token cookie works with the web Client-ID per
Streamlink's approach. Extract at login, persist to secure storage,
and attempt fresh extraction on init for existing logged-in users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
1020b37e2f add experimental native video player with iOS auto-PiP
Adds an opt-in native HLS video player (iOS-only, off by default) that
uses AVPlayerViewController for true automatic picture-in-picture on
swipe-home, replacing the WebView-based player when enabled.

Key changes:
- GQL API client for fetching playback access tokens and building HLS URLs
- NativeVideoStore (MobX) managing player lifecycle, quality selection, PiP
- VideoPlayerInterface abstraction so both stores drive the same overlay
- Settings toggle gated on iOS with "Native player (experimental)" label
- Auth token fallback: tries authenticated GQL for ad-free, falls back to
  anonymous if rejected (app OAuth token != web Client-ID token)
- Scoped UnauthorizedInterceptor to Helix/OAuth URLs only
- iOS Info.plist background audio mode for PiP audio continuity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
177 changed files with 9401 additions and 17314 deletions

15
.claude/settings.json Normal file
View file

@ -0,0 +1,15 @@
{
"enabledMcpjsonServers": [
"next-devtools"
],
"enabledPlugins": {
"frontend-design@claude-plugins-official": true,
"feature-dev@claude-plugins-official": true,
"commit-commands@claude-plugins-official": true,
"pr-review-toolkit@claude-plugins-official": true,
"typescript-lsp@claude-plugins-official": true,
"claude-md-management@claude-plugins-official": true,
"claude-code-setup@claude-plugins-official": true,
"skill-creator@claude-plugins-official": true
}
}

View file

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

View file

@ -1,14 +0,0 @@
# Temporary probe: does this repo schedule Actions at all?
name: Actions probe
on:
push:
branches: [main]
workflow_dispatch:
jobs:
probe:
runs-on: desktop-release
steps:
- name: ping
run: echo "frosty actions probe ok"

View file

@ -1,304 +0,0 @@
# Builds a signed Frosty release APK and publishes a Forgejo release for Obtainium.
# Pattern: finamp-fork / jellyfin-android-fork on host runner `desktop-release`.
#
# Required repository secrets:
# RELEASE_KEYSTORE_BASE64, STORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD
# CLIENT_ID, SECRET (Twitch Helix app credentials — --dart-define)
#
# Obtainium:
# Source: Forgejo
# URL: https://forgejo.fsociety00.cc/api/v1/repos/copilot/frosty/releases
# APK filter: frosty-arm64-v8a-v.*\.apk
# Trim version: v?([0-9]+\.[0-9]+\.[0-9]+)
# Match group: 1
# Private repo → Forgejo API token in Obtainium.
name: Release APK
on:
push:
tags:
- 'v*'
workflow_dispatch:
permissions:
contents: write
concurrency:
group: frosty-release
cancel-in-progress: true
jobs:
build-and-release:
name: Build & publish release
runs-on: desktop-release
steps:
- name: Clone repository
shell: bash
env:
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
rm -rf .git
git init .
origin_url="${GITHUB_SERVER_URL#https://}"
origin_url="${origin_url#http://}"
git remote add origin "https://copilot:${FORGEJO_TOKEN}@${origin_url}/${GITHUB_REPOSITORY}.git"
git -c http.sslVerify=false fetch --depth=1 origin "${GITHUB_SHA}"
git checkout --force FETCH_HEAD
test -f pubspec.yaml
test -f android/app/build.gradle.kts
echo "Checked out ${GITHUB_REPOSITORY}@${GITHUB_SHA}"
- name: Set up JDK
shell: bash
run: |
set -euo pipefail
pkg_install() {
if [ "$(id -u)" -eq 0 ]; then "$@"
elif command -v sudo >/dev/null 2>&1; then sudo "$@"
else "$@"; fi
}
if ! command -v java >/dev/null 2>&1; then
if command -v dnf >/dev/null 2>&1; then pkg_install dnf install -y java-21-openjdk-devel
elif command -v apt-get >/dev/null 2>&1; then
pkg_install apt-get update
pkg_install apt-get install -y openjdk-21-jdk
elif command -v apk >/dev/null 2>&1; then pkg_install apk add --no-cache openjdk21
fi
fi
java -version
if command -v javac >/dev/null 2>&1; then
java_home="$(dirname "$(dirname "$(readlink -f "$(command -v javac)")")")"
echo "JAVA_HOME=$java_home" >> "$GITHUB_ENV"
fi
- name: Set up Android SDK
shell: bash
run: |
set -euo pipefail
sdk_candidates=(
"${ANDROID_HOME:-}"
"${ANDROID_SDK_ROOT:-}"
"/home/yuna/Android/Sdk"
"${HOME}/Android/Sdk"
"/opt/android-sdk-linux"
"/root/Android/Sdk"
)
sdk_dir=""
for candidate in "${sdk_candidates[@]}"; do
if [ -n "$candidate" ] && [ -d "$candidate/platforms" ] && [ -d "$candidate/build-tools" ]; then
sdk_dir="$candidate"
break
fi
done
if [ -z "$sdk_dir" ]; then
echo "Android SDK not found on desktop-release host."
exit 1
fi
echo "ANDROID_HOME=$sdk_dir" >> "$GITHUB_ENV"
echo "ANDROID_SDK_ROOT=$sdk_dir" >> "$GITHUB_ENV"
printf 'sdk.dir=%s\n' "$sdk_dir" > android/local.properties
echo "Using Android SDK: $sdk_dir"
- name: Set up Flutter
shell: bash
run: |
set -euo pipefail
pkg_install() {
if [ "$(id -u)" -eq 0 ]; then "$@"
elif command -v sudo >/dev/null 2>&1; then sudo "$@"
else "$@"; fi
}
if ! command -v curl >/dev/null 2>&1; then
if command -v apk >/dev/null 2>&1; then pkg_install apk add --no-cache curl bash git unzip xz
elif command -v dnf >/dev/null 2>&1; then pkg_install dnf install -y curl git unzip
elif command -v apt-get >/dev/null 2>&1; then
pkg_install apt-get update
pkg_install apt-get install -y curl git unzip
fi
fi
# Prefer pinned SDK already on the desktop-release host.
flutter_version="$(awk '/^[[:space:]]*flutter:/{print $2; exit}' pubspec.yaml)"
flutter_version="${flutter_version:-3.47.0}"
candidates=(
"/home/yuna/.local/flutter-${flutter_version}"
"${HOME}/.cache/frosty-ci-flutter-${flutter_version}"
"/home/yuna/.local/flutter"
)
flutter_home=""
for c in "${candidates[@]}"; do
if [ -x "${c}/bin/flutter" ]; then
flutter_home="$c"
break
fi
done
if [ -z "$flutter_home" ]; then
flutter_home="${HOME}/.cache/frosty-ci-flutter-${flutter_version}"
rm -rf "${flutter_home}"
git clone --depth 1 --branch "${flutter_version}" https://github.com/flutter/flutter.git "${flutter_home}"
fi
echo "${flutter_home}/bin" >> "$GITHUB_PATH"
echo "PATH=${flutter_home}/bin:${PATH}" >> "$GITHUB_ENV"
export PATH="${flutter_home}/bin:${PATH}"
flutter --version
flutter precache --android
- name: Decode keystore
shell: bash
env:
RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: |
set -euo pipefail
if [ -z "${RELEASE_KEYSTORE_BASE64:-}" ] || [ -z "${STORE_PASSWORD:-}" ] || [ -z "${KEY_ALIAS:-}" ] || [ -z "${KEY_PASSWORD:-}" ]; then
echo "Missing signing secrets: RELEASE_KEYSTORE_BASE64, STORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD"
exit 1
fi
printf '%s' "$RELEASE_KEYSTORE_BASE64" | base64 -d > android/upload-keystore.jks
test -s android/upload-keystore.jks
{
echo "storePassword=${STORE_PASSWORD}"
echo "keyPassword=${KEY_PASSWORD}"
echo "keyAlias=${KEY_ALIAS}"
echo "storeFile=${GITHUB_WORKSPACE}/android/upload-keystore.jks"
} > android/key.properties
echo "Keystore ready ($(wc -c < android/upload-keystore.jks) bytes)."
- name: Resolve version metadata
id: version
shell: bash
run: |
set -euo pipefail
raw="$(grep -E '^version:' pubspec.yaml | head -1 | awk '{print $2}')"
VERSION_NAME="${raw%%+*}"
VERSION_CODE="${raw##*+}"
[ "$VERSION_CODE" != "$raw" ] || VERSION_CODE=1
SHORT_SHA="${GITHUB_SHA::7}"
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
TAG="${GITHUB_REF#refs/tags/}"
else
TAG="build-${VERSION_NAME}-${VERSION_CODE}-${SHORT_SHA}"
fi
echo "version_name=${VERSION_NAME}" >> "$GITHUB_OUTPUT"
echo "version_code=${VERSION_CODE}" >> "$GITHUB_OUTPUT"
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG} version=${VERSION_NAME}+${VERSION_CODE}"
- name: Build signed release APK
id: build
shell: bash
env:
CLIENT_ID: ${{ secrets.CLIENT_ID }}
SECRET: ${{ secrets.SECRET }}
VERSION_NAME: ${{ steps.version.outputs.version_name }}
VERSION_CODE: ${{ steps.version.outputs.version_code }}
run: |
set -euo pipefail
if [ -z "${CLIENT_ID:-}" ] || [ -z "${SECRET:-}" ]; then
echo "Missing Twitch secrets CLIENT_ID / SECRET (Helix OAuth app)."
exit 1
fi
flutter pub get
flutter build apk --release \
--build-name="${VERSION_NAME}" \
--build-number="${VERSION_CODE}" \
--dart-define="CLIENT_ID=${CLIENT_ID}" \
--dart-define="SECRET=${SECRET}"
mkdir -p dist
cp build/app/outputs/flutter-apk/app-release.apk \
"dist/frosty-v${VERSION_NAME}.apk"
flutter build apk --release --split-per-abi \
--build-name="${VERSION_NAME}" \
--build-number="${VERSION_CODE}" \
--dart-define="CLIENT_ID=${CLIENT_ID}" \
--dart-define="SECRET=${SECRET}" || true
for abi in arm64-v8a armeabi-v7a x86_64; do
src="build/app/outputs/flutter-apk/app-${abi}-release.apk"
if [ -f "$src" ]; then
cp "$src" "dist/frosty-${abi}-v${VERSION_NAME}.apk"
fi
done
ls -lh dist/
- name: Publish Forgejo release
shell: bash
env:
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.version.outputs.tag }}
VERSION_NAME: ${{ steps.version.outputs.version_name }}
VERSION_CODE: ${{ steps.version.outputs.version_code }}
SHORT_SHA: ${{ steps.version.outputs.short_sha }}
run: |
set -euo pipefail
pkg_install() {
if [ "$(id -u)" -eq 0 ]; then "$@"
elif command -v sudo >/dev/null 2>&1; then sudo "$@"
else return 1; fi
}
for pkg in curl jq; do
command -v "$pkg" >/dev/null 2>&1 && continue
if command -v apk >/dev/null 2>&1; then pkg_install apk add --no-cache "$pkg"
elif command -v dnf >/dev/null 2>&1; then pkg_install dnf install -y "$pkg"
elif command -v apt-get >/dev/null 2>&1; then
pkg_install apt-get update
pkg_install apt-get install -y "$pkg"
fi
done
API="${GITHUB_API_URL:-${GITHUB_SERVER_URL}/api/v1}/repos/${GITHUB_REPOSITORY}"
AUTH="Authorization: token ${FORGEJO_TOKEN}"
BODY_FILE="$(mktemp)"
cat > "${BODY_FILE}" <<BODY_EOF
Frosty fork (Twitch client) — native player + TwitchAdSolutions adblock.
| | |
|---|---|
| Version | ${VERSION_NAME} (${VERSION_CODE}) |
| Commit | ${SHORT_SHA} |
| Package | \`com.tommychow.frosty\` |
**Obtainium:** Forgejo source → \`…/copilot/frosty/releases\`, APK filter \`frosty-arm64-v8a-v.*\\.apk\`, trim \`v?([0-9]+\\.[0-9]+\\.[0-9]+)\` group \`1\`.
Stock Play Frosty uses a different signing key — uninstall it before installing this fork, or Obtainium cannot update in place.
BODY_EOF
# Create or update release for TAG
existing="$(curl -sS -H "$AUTH" "${API}/releases/tags/${TAG}" || true)"
release_id="$(printf '%s' "$existing" | jq -r '.id // empty')"
if [ -z "$release_id" ]; then
payload="$(jq -n \
--arg tag "$TAG" \
--arg name "Frosty ${VERSION_NAME} (${SHORT_SHA})" \
--rawfile body "$BODY_FILE" \
'{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:false}')"
resp="$(curl -sS -X POST -H "$AUTH" -H 'Content-Type: application/json' \
-d "$payload" "${API}/releases")"
release_id="$(printf '%s' "$resp" | jq -r '.id')"
else
payload="$(jq -n \
--arg name "Frosty ${VERSION_NAME} (${SHORT_SHA})" \
--rawfile body "$BODY_FILE" \
'{name:$name, body:$body, draft:false, prerelease:false}')"
curl -sS -X PATCH -H "$AUTH" -H 'Content-Type: application/json' \
-d "$payload" "${API}/releases/${release_id}" >/dev/null
fi
test -n "$release_id" && test "$release_id" != "null"
for apk in dist/*.apk; do
[ -f "$apk" ] || continue
name="$(basename "$apk")"
echo "Uploading ${name}…"
curl -sS -X POST -H "$AUTH" \
-H "Content-Type: application/octet-stream" \
--data-binary @"${apk}" \
"${API}/releases/${release_id}/assets?name=${name}" \
| jq -r '.name // .message'
done
echo "Published ${TAG} as release ${release_id}"
rm -f "$BODY_FILE"

View file

@ -3,57 +3,18 @@ on:
push:
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: 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

@ -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.
@ -97,7 +96,7 @@ jobs:
# Create a draft release on GitHub.
- name: Release
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@v2
with:
draft: true
generate_release_notes: ${{ steps.release_notes.outputs.found != 'true' }}

View file

@ -8,36 +8,10 @@ jobs:
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: 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

@ -35,7 +35,7 @@ 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 Java so that we can build the appbundle.
@ -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.
@ -85,7 +84,7 @@ jobs:
- name: Checkout Frosty repo
uses: actions/checkout@v6
# Always pick the newest production Xcode on the runner.
# Always pick the newest production Xcode on the runner
- uses: maxim-lobanov/setup-xcode@v1
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).

2
.gitignore vendored
View file

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

View file

@ -1,9 +1,5 @@
{
"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,46 +1,55 @@
# AGENTS.md
Flutter Twitch client for iOS and Android. MobX with `build_runner`. The landing page is `web/` (own `AGENTS.md`). Flutter is pinned in `pubspec.yaml` `environment.flutter` — bump that one field; CI reads it via `flutter-version-file`.
> **Monorepo note:** `web/` is a separate Next.js landing page with its own `AGENTS.md`. This file covers the Flutter app only.
## Workflow
- In plan mode, interview thoroughly — ask about UI/UX, tradeoffs, and edge cases before coding
- When new code supersedes existing functionality, find and remove everything it makes redundant
- Favor parallel tool calls and subagents when tasks are independent
## Commands
```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
flutter pub get # Install dependencies
flutter run --dart-define=clientId=ID --dart-define=secret=SECRET # Run with Twitch credentials
flutter analyze # Static analysis (run after changes)
flutter test # Run tests (if changes touch testable logic)
dart run build_runner build # Regenerate .g.dart files (MobX/JSON models)
dart run build_runner build --delete-conflicting-outputs # Same, but clears stale outputs
```
`--delete-conflicting-outputs` is ignored as of build_runner 2.15. Don't add it.
## Source Structure
## Layout
- `lib/apis/` — API services (all extend `BaseApiClient`)
- `lib/models/` — Data models (`@JsonSerializable` with `.g.dart` codegen)
- `lib/screens/{feature}/` — Feature screens with co-located `stores/` subdirectories
- `lib/stores/` — Global stores only (e.g., `global_assets_store.dart`)
- `lib/services/` — App-level services
- `lib/widgets/` — Shared widgets
- `lib/utils/` — Utility functions
- `lib/apis/` — API clients extend `BaseApiClient`
- `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/`
## Architecture (non-obvious bits)
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`.
- Feature stores live in `lib/screens/{feature}/stores/` — don't create new top-level store files (only `lib/stores/` holds global stores)
- All API services extend `BaseApiClient` (`lib/apis/base_api_client.dart`) — don't create standalone API classes
- MobX stores use a generated mixin pattern — see `lib/screens/settings/stores/auth_store.dart` for the canonical example
## 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.
- After changing MobX stores or `@JsonSerializable` models, regenerate with `dart run build_runner build`. Never edit `.g.dart` files directly. Commit `.g.dart` files to source control.
- The secure storage cleanup in `main.dart` looks unnecessary but handles an Android/iOS edge case where secure storage persists after uninstall. Don't remove it.
- Use package imports (`import 'package:frosty/...'`), not relative imports
- Always include trailing commas
- Use single quotes
## Releases
## Testing
When cutting a store or GitHub release, read `docs/release.md` and follow it. That playbook is repo-local, not a global skill. `assets/release-notes.md` keeps about three consecutive versions; drop from the oldest end, never skip.
- `flutter test` runs all tests; `flutter test test/path/to/file.dart` for a single file
- HTTP mocking: `http_mock_adapter` (`DioAdapter`) — use full URLs in `onGet`/`onPost`
- General mocking: `mocktail` (no codegen required)
- Fixtures live in `test/fixtures/` (e.g., `irc_messages.dart`, `api_responses.dart`)
## Commits
Lowercase, no prefixes (e.g., `fix landscape bottom padding in chat bottom bar`). Keep commits tightly scoped.

View file

@ -1,61 +0,0 @@
# Frosty fork — Twitch ad blocking (DESIGN)
Personal Forgejo fork of [tommyxchow/frosty](https://github.com/tommyxchow/frosty) that adds **TwitchAdSolutions (vaft)**-style live ad blocking.
Canonical living plan: session `active-plan.md` for conversation `37b527bf-e540-4753-be1f-39906fe2ba28`. ADR: **ADR-026**.
## Problem
Upstream Frosty plays Twitch HLS (native) or `player.twitch.tv` (WebView). Twitch may stitch ad pods into the media playlist (`stitched` / `EXT-X-DATERANGE`). Upstream only **detects** native ad breaks (to avoid bogus latency recovery); it does **not** block ads. Turbo/sub `hide_ads` via linked web session is separate and not a client blocker.
## Approach (vaft-equivalent)
Browser vaft hooks `Worker`/`fetch` and rewrites m3u8. Frosty’s native player cannot do that from Dart (`loadUrl` + headers only).
| Layer | Behavior |
| --- | --- |
| Primary token | GQL `PlaybackAccessToken` with `playerType=popout` (vaft `ForceAccessTokenPlayerType`) when Settings `blockTwitchAds` is on |
| Ad break | Native `adBreakStream` → probe backup types `embed` → `popout` → `autoplay`; accept first Usher multivariant whose sampled media playlist lacks `stitched`; `loadUrl` that URL |
| Ad end | Restore primary popout Usher URL |
| WebView | Best-effort inject of vendored `assets/js/vaft.user.js` (not true document-start on Android) |
| UI | Settings toggle (default on; enabling forces native player); overlay “Blocking ads” |
## Key code
- `lib/apis/twitch_ad_solutions.dart` — constants + `TwitchAdFreePlayback`
- `lib/apis/twitch_gql_api.dart` — `playerType` / `platform`, `fetchPlaylistText`
- `lib/screens/channel/video/native_video_store.dart` — load + swap
- `lib/screens/channel/video/video_store.dart` — WebView inject
- `lib/screens/settings/stores/settings_store.dart` — `blockTwitchAds`
- `assets/js/vaft.user.js` — upstream TwitchAdSolutions script
## Non-goals (v1)
- Upstream PR
- Full Worker prototype cleaning
- Third-party proxies
- HLS segment stripping inside `better_native_video_player` (unless smoke tests demand it)
## Tags + viewer sort (v5.2.2+)
Helix cannot filter by freeform tags or sort ascending. GQL covers both:
- Search: `SearchFreeformTags` → tap → `TagStreams` (`streams` + `freeformTags`)
- Sort: Top (asc), category (`game.streams`), tag — `VIEWER_COUNT` / `VIEWER_COUNT_ASC` via `StreamSortButton`
Key code: `lib/apis/twitch_gql_api.dart`, `lib/screens/home/search/`, `lib/widgets/stream_sort_button.dart`, `ListStore` / `ListType.tag`.
## Stream languages (v5.2.4+)
Settings → General → Stream languages (multi-select chips). Default `de`. Applied to Top, category, and tag lists.
- Helix `language=` when one language; multi-language / tags via Helix over-fetch + client filter
- Viewer sort is persisted (`streamViewerSort`)
- **Tag lists (asc + desc):** GQL `streams` with `freeformTags` + `sort` + cursor. Page-2+ uses Client-Integrity (same mint/retry as follow via `UserStore.runWithGqlIntegrity`). Helix + client tag filter only if integrity fails / no web session.
- **Follow / integrity:** Dio `POST /integrity` is almost always `is_bad_bot`. Follow retries via `IntegrityWebViewDialog` (capture `Client-Integrity` + `X-Device-Id` from Twitch's own WebView GQL traffic), then `UserStore.applyWebIntegrity`.
- **Top / category most viewers (desc):** Helix directory order (paginates)
- **Top / category fewest (asc):** GQL `VIEWER_COUNT_ASC` with the same integrity pagination; Helix fill + client re-sort as fallback
## Build
Flutter **3.47.0** (pinned in `pubspec.yaml`). Local SDK: `/home/yuna/.local/flutter-3.47.0`.

View file

@ -16,9 +16,6 @@ analyzer:
- ios/**
- android/**
- web/**
- windows/**
- macos/**
- linux/**
linter:
# The lint rules applied to this project can be customized in the

View file

@ -3,13 +3,13 @@ GEM
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.9.0)
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.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-partitions (1.1200.0)
aws-sdk-core (3.240.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@ -17,19 +17,18 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.129.0)
aws-sdk-core (~> 3, >= 3.248.0)
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.225.1)
aws-sdk-core (~> 3, >= 3.248.0)
aws-sdk-s3 (1.209.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
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)
bigdecimal (4.0.1)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
@ -43,7 +42,7 @@ GEM
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.112.0)
faraday (1.10.5)
faraday (1.10.4)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
@ -68,20 +67,19 @@ GEM
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.230.0)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
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)
base64 (~> 0.2.0)
bundler (>= 1.12.0, < 3.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
@ -92,11 +90,11 @@ 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)
@ -105,11 +103,10 @@ GEM
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)
mutex_m (~> 0.3.0)
naturally (~> 2.2)
nkf (~> 0.2)
nkf (~> 0.2.0)
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)
@ -122,42 +119,41 @@ GEM
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
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.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.8.0)
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.5.0)
google-cloud-storage (1.47.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.31.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 +164,42 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.19.9)
jwt (2.10.3)
json (2.18.0)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.21.1)
multi_json (1.19.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
nkf (0.3.0)
nkf (0.2.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
public_suffix (7.0.5)
rake (13.4.2)
public_suffix (7.0.0)
rake (13.3.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)
retriable (3.1.2)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.22.0)
signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.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)

View file

@ -6,6 +6,7 @@ plugins {
id("com.google.gms.google-services")
id("com.google.firebase.firebase-perf")
id("com.google.firebase.crashlytics")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
@ -26,6 +27,10 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
applicationId = "com.tommychow.frosty"
minSdk = flutter.minSdkVersion
@ -45,26 +50,11 @@ android {
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")
}
signingConfig = signingConfigs.getByName("release")
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}

View file

@ -1,5 +1,3 @@
import com.android.build.api.variant.LibraryAndroidComponentsExtension
allprojects {
repositories {
google()
@ -21,17 +19,6 @@ 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,4 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -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
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
org.gradle.caching=true
org.gradle.parallel=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.14-all.zip

View file

@ -18,8 +18,8 @@ pluginManagement {
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.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
id("com.google.gms.google-services") version "4.4.4" apply false
id("com.google.firebase.firebase-perf") version "2.0.2" apply false
id("com.google.firebase.crashlytics") version "3.0.6" apply false

File diff suppressed because it is too large Load diff

View file

@ -1,28 +1,113 @@
## v5.3.1
## v5.0.5
October 12, 2026
January 9, 2026
### Bug Fixes
- Fixed the player getting stuck on a Source error after the screen turns off or the network switches — it now reloads automatically instead of waiting for a manual refresh.
- Fixed lag/frame drops in overlay chat mode.
- Fixed draggable divider still interactable in fullscreen mode.
- Fixed landscape bottom padding in chat bottom bar.
- [iOS] Fixed user agent breaking login.
---
## v5.3.0
## v5.0.4
October 12, 2026
January 8, 2026
### Improvements
- Swipe down to PiP gesture now works in landscape mode.
- The rotate button now adjusts to your device's physical orientation instead of always rotating to the same direction.
- Replaced "Fill notch side" setting with "Fill all edges" and made edge padding auto-adjust based on device rotation.
### Bug Fixes
- Fixed Google login not working.
- [iOS] Fixed bottom safe area in landscape mode.
---
## v5.0.3
January 6, 2026
### Improvements
- Reconnection messages now update in place instead of spamming chat.
- Chat now shows sending status and relevant error notifications when messages fail to send.
- [Android] The "Enhanced rendering" video setting has been renamed to "Fast video rendering" and reset to enabled for all users.
### Bug Fixes
- Fixed send button not working in landscape mode.
- Fixed text field input being cleared before server confirmation.
---
## v5.0.2
January 4, 2026
### Improvements
- Inline emote support in the chat text field.
- Search page now uses type-to-search for faster navigation.
- Restored ability to send messages while chat delay is active.
- Restored chat delay input indicator.
- Redesigned the reply bar.
### Bug Fixes
- Fixed reply bar and autocomplete bar blocking the bottom of chat.
- Fixed clear recent emotes button sizing.
- Fixed black screen when logging in.
---
## v5.0.1
January 2, 2026
### Improvements
- [Android] Enhanced video rendering is now enabled by default for better performance. Disable in settings if you experience crashes.
- [Android] Re-enabled Flutter Impeller rendering engine for better performance.
- Restored the "Show latency" setting in video overlay options.
### Bug Fixes
- [Android] Potentially fixed crash on app startup related to secure storage.
---
## v5.0.0
January 1, 2026
### New Features
- Added a Follow / Unfollow button under the player (between video and chat) for live and offline channels.
- Open multiple chat tabs for different channels with drag-to-reorder. Tabs are saved between sessions by default (configurable in settings).
- Resize the chat and video areas in landscape mode by dragging the divider.
- A new section in the Following tab shows which of your followed channels are currently offline.
- Change your chat username color from the chat details menu.
- A new randomize button for the accent color theme in settings.
- View emotes and stream thumbnails in full resolution with swipe-to-dismiss gestures.
- [Android] Open Twitch channel links directly in Frosty instead of the browser or Twitch app (by @micahmo on GitHub).
---
### Improvements
## v5.2.9
October 7, 2026
- Swipe down to enter Picture-in-Picture mode, and press the PiP button again to exit.
- Redesigned the video overlay and added stream details to the app bar in chat-only mode.
- Networking and data handling have been completely rewritten to be faster and more reliable.
- Improved formatting for chat replies, quick copy-to-input for messages, and a clearer countdown for chat delays.
### Bug Fixes
- Fixed 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 chat text input not unfocusing when tapping outside of it (by @zeykafx on GitHub).
- Fixed missing badges and emotes in historical messages upon initial connection.
- Fixed accidental chat scrolling when accessing system UI gestures in landscape mode.
- Fixed chat reconnection issues when a chat delay was active.
- Fixed duplicate message cases and various UI stability issues.
#### See all release notes at [https://github.com/tommyxchow/frosty/releases](https://github.com/tommyxchow/frosty/releases)

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>15.0</string>
</dict>
</plist>

View file

@ -3,13 +3,13 @@ GEM
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.9.0)
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.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-partitions (1.1200.0)
aws-sdk-core (3.240.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@ -17,19 +17,18 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.129.0)
aws-sdk-core (~> 3, >= 3.248.0)
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.225.1)
aws-sdk-core (~> 3, >= 3.248.0)
aws-sdk-s3 (1.209.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
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)
bigdecimal (4.0.1)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
@ -43,7 +42,7 @@ GEM
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.112.0)
faraday (1.10.5)
faraday (1.10.4)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
@ -68,20 +67,19 @@ GEM
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.230.0)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
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)
base64 (~> 0.2.0)
bundler (>= 1.12.0, < 3.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
@ -92,11 +90,11 @@ 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)
@ -105,11 +103,10 @@ GEM
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)
mutex_m (~> 0.3.0)
naturally (~> 2.2)
nkf (~> 0.2)
nkf (~> 0.2.0)
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)
@ -122,42 +119,41 @@ GEM
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
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.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.8.0)
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.5.0)
google-cloud-storage (1.47.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.31.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 +164,42 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.19.9)
jwt (2.10.3)
json (2.18.0)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.21.1)
multi_json (1.19.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
nkf (0.3.0)
nkf (0.2.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
public_suffix (7.0.5)
rake (13.4.2)
public_suffix (7.0.0)
rake (13.3.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)
retriable (3.1.2)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.22.0)
signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.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)

View file

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

View file

@ -17,7 +17,6 @@
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 */
@ -54,7 +53,6 @@
CFF3F6A0DA5A835B219C2D3C /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
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 +60,6 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -73,7 +70,6 @@
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@ -141,9 +137,6 @@
/* Begin PBXNativeTarget section */
97C146ED1CF9000F007C117D /* Runner */ = {
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
@ -170,9 +163,6 @@
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1510;
@ -259,7 +249,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;
@ -604,18 +594,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,13 @@ 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)
CookieExtractorPlugin.register(with: self.registrar(forPlugin: "CookieExtractorPlugin")!)
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

@ -15,8 +15,7 @@ class CookieExtractorPlugin: NSObject, FlutterPlugin {
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"))
$0.name == "auth-token" && $0.domain.contains("twitch.tv")
}
result(authCookie?.value)
}

View file

@ -52,27 +52,6 @@
<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>

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 @@
Frosty
Frosty for Twitch

View file

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

View file

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

View file

@ -44,13 +44,6 @@ class UnauthorizedException extends ApiException {
: super(message, 401, DioExceptionType.badResponse);
}
/// 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;
@ -168,8 +161,7 @@ abstract class BaseApiClient {
final exception = switch (error.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.sendTimeout ||
DioExceptionType.receiveTimeout ||
DioExceptionType.transformTimeout =>
DioExceptionType.receiveTimeout =>
TimeoutException(_getTimeoutMessage(error.type)),
DioExceptionType.connectionError => NetworkException(
'No internet connection. Please check your network.',
@ -187,15 +179,11 @@ abstract class BaseApiClient {
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).
}
FirebaseCrashlytics.instance.recordError(
exception,
error.stackTrace,
reason: '${error.requestOptions.method} ${error.requestOptions.uri}',
);
}
return exception;
@ -210,8 +198,6 @@ abstract class BaseApiClient {
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.';
}

View file

@ -42,7 +42,7 @@ class FFZApi extends BaseApiClient {
);
}
/// Returns a map of user IDs to a list of their FFZ badges.
/// Returns a map of badges user IDs to a list of their FFZ badges.
Future<Map<String, List<ChatBadge>>> getBadges() async {
final data = await get<JsonMap>('/badges/ids');

View file

@ -1,163 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:frosty/models/playback_access_token.dart';
/// Dart-side port of the [TwitchAdSolutions vaft](https://github.com/pixeltris/TwitchAdSolutions)
/// strategy for Frosty's native HLS player.
///
/// Browser vaft hooks `Worker`/`fetch` to rewrite playlists. Here we instead:
/// 1. Request playback tokens with a non-`site` [forceAccessTokenPlayerType]
/// (vaft's `ForceAccessTokenPlayerType`).
/// 2. When stitched ads still appear, try [backupPlayerTypes] until a playlist
/// without the ad signifier is found, then hand that Usher URL to the player.
class TwitchAdSolutions {
TwitchAdSolutions._();
/// Matches vaft `AdSignifier` — Twitch marks ad pods with this in `#EXT-X-DATERANGE`.
static const adSignifier = 'stitched';
/// vaft `ForceAccessTokenPlayerType` — used for the primary stream when
/// ad blocking is enabled.
static const forceAccessTokenPlayerType = 'popout';
/// vaft `BackupPlayerTypes` order when the primary playlist still has ads.
static const backupPlayerTypes = <String>[
'embed',
'popout',
'autoplay',
];
/// vaft `FallbackPlayerType` when every backup still carries ads.
static const fallbackPlayerType = 'embed';
/// GQL `platform` for a given player type (vaft uses `android` for autoplay).
static String platformForPlayerType(String playerType) =>
playerType == 'autoplay' ? 'android' : 'web';
static bool playlistContainsAds(String playlistText) =>
playlistText.contains(adSignifier);
/// True when [text] looks like a midroll (vaft midroll banner path).
static bool playlistIsMidroll(String playlistText) =>
playlistText.contains('"MIDROLL"') ||
playlistText.contains('"midroll"') ||
playlistText.contains("MIDROLL");
}
/// Resolves Usher HLS URLs using TwitchAdSolutions player-type strategy.
class TwitchAdFreePlayback {
TwitchAdFreePlayback(this._gql);
final TwitchGqlApi _gql;
/// Primary URL when adblock is on: force [TwitchAdSolutions.forceAccessTokenPlayerType].
Future<({String url, PlaybackAccessToken token, String playerType})>
resolvePrimary({
required String login,
String? authToken,
}) {
return _resolveForPlayerType(
login: login,
authToken: authToken,
playerType: TwitchAdSolutions.forceAccessTokenPlayerType,
requireAdFree: false,
);
}
/// Walks [TwitchAdSolutions.backupPlayerTypes] for a playlist without ads.
/// Falls back to [TwitchAdSolutions.fallbackPlayerType] even if ads remain.
Future<({String url, PlaybackAccessToken token, String playerType})>
resolveBackup({
required String login,
String? authToken,
}) async {
for (final playerType in TwitchAdSolutions.backupPlayerTypes) {
try {
final resolved = await _resolveForPlayerType(
login: login,
authToken: authToken,
playerType: playerType,
requireAdFree: true,
);
debugPrint(
'TwitchAdFreePlayback: backup OK playerType=$playerType',
);
return resolved;
} catch (e) {
debugPrint(
'TwitchAdFreePlayback: backup playerType=$playerType failed: $e',
);
}
}
debugPrint(
'TwitchAdFreePlayback: all backups had ads; using fallback '
'${TwitchAdSolutions.fallbackPlayerType}',
);
return _resolveForPlayerType(
login: login,
authToken: authToken,
playerType: TwitchAdSolutions.fallbackPlayerType,
requireAdFree: false,
);
}
Future<({String url, PlaybackAccessToken token, String playerType})>
_resolveForPlayerType({
required String login,
String? authToken,
required String playerType,
required bool requireAdFree,
}) async {
final token = await _gql.getPlaybackAccessToken(
login: login,
authToken: authToken,
playerType: playerType,
platform: TwitchAdSolutions.platformForPlayerType(playerType),
);
final url = _gql.buildHlsUrl(login: login, token: token);
if (requireAdFree) {
final hasAds = await _multivariantHasAds(url);
if (hasAds) {
throw StateError('Playlist for playerType=$playerType still has ads');
}
}
return (url: url, token: token, playerType: playerType);
}
/// Fetches the multivariant playlist and samples the first media variant for
/// the ad signifier (same idea as vaft probing backup encodings).
Future<bool> _multivariantHasAds(String hlsUrl) async {
final master = await _gql.fetchPlaylistText(hlsUrl);
final mediaUrl = firstMediaPlaylistUrl(master, baseUrl: hlsUrl);
if (mediaUrl == null) {
// Can't verify — treat as ad-free so we don't reject a usable stream.
return false;
}
final media = await _gql.fetchPlaylistText(mediaUrl);
return TwitchAdSolutions.playlistContainsAds(media);
}
/// First `.m3u8` URI in a multivariant playlist, resolved against [baseUrl].
@visibleForTesting
static String? firstMediaPlaylistUrl(
String masterPlaylist, {
required String baseUrl,
}) {
final lines = masterPlaylist.replaceAll('\r', '').split('\n');
for (final raw in lines) {
final line = raw.trim();
if (line.isEmpty || line.startsWith('#')) continue;
if (!line.contains('.m3u8')) continue;
return Uri.parse(baseUrl).resolve(line).toString();
}
return null;
}
}
/// Tiny blank MP4 (same payload vaft serves for stripped ad segments). Exposed
/// for tests / future local playlist rewriting; not required for URL-swap mode.
const twitchAdSolutionsBlankSegmentDataUri =
'data:video/mp4;base64,AAAAKGZ0eXBtcDQyAAAAAWlzb21tcDQyZGFzaGF2YzFpc282aGxzZgAABEltb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAYagAAAAAAABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAABqHRyYWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAURtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAALuAAAAAAFXEAAAAAAAtaGRscgAAAAAAAAAAc291bgAAAAAAAAAAAAAAAFNvdW5kSGFuZGxlcgAAAADvbWluZgAAABBzbWhkAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAACzc3RibAAAAGdzdHNkAAAAAAAAAAEAAABXbXA0YQAAAAAAAAABAAAAAAAAAAAAAgAQAAAAALuAAAAAAAAzZXNkcwAAAAADgICAIgABAASAgIAUQBUAAAAAAAAAAAAAAAWAgIACEZAGgICAAQIAAAAQc3R0cwAAAAAAAAAAAAAAEHN0c2MAAAAAAAAAAAAAABRzdHN6AAAAAAAAAAAAAAAAAAAAEHN0Y28AAAAAAAAAAAAAAeV0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAoAAAAFoAAAAAAGBbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAA9CQAAAAABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABLG1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAOxzdGJsAAAAoHN0c2QAAAAAAAAAAQAAAJBhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAoABaABIAAAASAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAOmF2Y0MBTUAe/+EAI2dNQB6WUoFAX/LgLUBAQFAAAD6AAA6mDgAAHoQAA9CW7y4KAQAEaOuPIAAAABBzdHRzAAAAAAAAAAAAAAAQc3RzYwAAAAAAAAAAAAAAFHN0c3oAAAAAAAAAAAAAAAAAAAAQc3RjbwAAAAAAAAAAAAAASG12ZXgAAAAgdHJleAAAAAAAAAABAAAAAQAAAC4AAAAAAoAAAAAAACB0cmV4AAAAAAAAAAIAAAABAACCNQAAAAACQAAA';

View file

@ -10,6 +10,7 @@ import 'package:frosty/models/followed_channel.dart';
import 'package:frosty/models/shared_chat_session.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/models/user.dart';
import 'package:frosty/models/video.dart';
/// The Twitch service for making API calls.
class TwitchApi extends BaseApiClient {
@ -145,29 +146,17 @@ class TwitchApi extends BaseApiClient {
return data['access_token'] as String;
}
/// Validates [token] with Twitch.
///
/// Returns token info (scopes + optional `user_id`) when valid, or `null`
/// when invalid/expired. App-access tokens may omit `user_id` and return an
/// empty scope list.
Future<TwitchTokenInfo?> validateToken({required String token}) async {
/// Returns a bool indicating the validity of the given token.
Future<bool> validateToken({required String token}) async {
try {
final data = await get<JsonMap>(
await get<JsonMap>(
'$_oauthBaseUrl/validate',
headers: {'Authorization': 'Bearer $token'},
);
final scopes = data['scopes'];
final scopeList = scopes is List
? scopes.map((scope) => scope.toString()).toList()
: const <String>[];
final userId = data['user_id']?.toString();
return TwitchTokenInfo(
scopes: scopeList,
userId: userId == null || userId.isEmpty ? null : userId,
);
return true;
} on UnauthorizedException {
// 401 -> token is invalid/expired (propagated from interceptor for validate requests)
return null;
return false;
} on ApiException catch (e) {
// Network/timeout/server errors -> treat as indeterminate, not invalid
debugPrint('Token validation indeterminate: $e');
@ -175,23 +164,11 @@ class TwitchApi extends BaseApiClient {
}
}
/// Returns a [StreamsTwitch] object that contains top streams and a cursor for further requests.
///
/// [language] is an optional ISO 639-1 code (Helix accepts only one).
Future<StreamsTwitch> getTopStreams({
String? cursor,
String? language,
int first = 100,
}) async {
final queryParams = <String, dynamic>{
'first': first,
if (cursor != null) 'after': cursor,
if (language != null && language.isNotEmpty) 'language': language,
};
/// Returns a [StreamsTwitch] object that contains the top 20 streams and a cursor for further requests.
Future<StreamsTwitch> getTopStreams({String? cursor}) async {
final data = await get<JsonMap>(
'/streams',
queryParameters: queryParams,
queryParameters: cursor != null ? {'after': cursor} : null,
);
return StreamsTwitch.fromJson(data);
@ -202,7 +179,7 @@ class TwitchApi extends BaseApiClient {
required String id,
String? cursor,
}) async {
final queryParams = {'user_id': id, 'first': '100'};
final queryParams = {'user_id': id};
if (cursor != null) queryParams['after'] = cursor;
final data = await get<JsonMap>(
@ -218,7 +195,7 @@ class TwitchApi extends BaseApiClient {
required String userId,
String? cursor,
}) async {
final queryParams = {'user_id': userId, 'first': '100'};
final queryParams = {'user_id': userId, 'first': '20'};
if (cursor != null) queryParams['after'] = cursor;
final data = await get<JsonMap>(
@ -229,153 +206,36 @@ class TwitchApi extends BaseApiClient {
return FollowedChannels.fromJson(data);
}
/// Whether [userId] follows [broadcasterId], via Helix (read-only; create/delete
/// follows were removed from the public API).
Future<bool> isFollowingChannel({
/// Returns a [VideosTwitch] object containing past broadcasts for the given [userId].
Future<VideosTwitch> getVideos({
required String userId,
required String broadcasterId,
String? cursor,
}) async {
final data = await get<JsonMap>(
'/channels/followed',
queryParameters: {
'user_id': userId,
'broadcaster_id': broadcasterId,
'first': '1',
},
);
final queryParams = <String, String>{
'user_id': userId,
'first': '20',
'type': 'archive',
};
if (cursor != null) queryParams['after'] = cursor;
final list = data['data'] as List? ?? const [];
return list.isNotEmpty;
final data = await get<JsonMap>('/videos', queryParameters: queryParams);
return VideosTwitch.fromJson(data);
}
/// Returns a [StreamsTwitch] object that contains the list of streams under the given game/category ID.
Future<StreamsTwitch> getStreamsUnderCategory({
required String gameId,
String? cursor,
String? language,
int first = 100,
}) async {
final queryParams = <String, dynamic>{
'game_id': gameId,
'first': first,
if (cursor != null) 'after': cursor,
if (language != null && language.isNotEmpty) 'language': language,
};
final queryParams = {'game_id': gameId};
if (cursor != null) queryParams['after'] = cursor;
final data = await get<JsonMap>('/streams', queryParameters: queryParams);
return StreamsTwitch.fromJson(data);
}
/// Fetches Helix directory pages until enough streams match [languages] and
/// optional [freeformTag] (case-insensitive tag name), or the directory ends.
///
/// Helix is always viewer-desc and supports at most one `language` query
/// param; tags are filtered client-side. Used as fallback when GQL directory
/// pagination lacks a Client-Integrity token.
///
/// When a [freeformTag] filter yields no matches in a Helix window, this
/// keeps scanning (up to [maxPages]) and still returns the Helix cursor so
/// callers can continue — an empty match set alone is not end-of-list.
Future<StreamsTwitch> getDirectoryStreams({
List<String> languages = const [],
String? gameId,
String? freeformTag,
String? cursor,
int minCount = 50,
int maxPages = 12,
}) async {
final wantedLangs = languages
.map((l) => l.trim().toLowerCase())
.where((l) => l.isNotEmpty)
.toSet();
final tagNeedle = freeformTag?.trim().toLowerCase();
final hasTag = tagNeedle != null && tagNeedle.isNotEmpty;
bool matches(StreamTwitch stream) {
if (wantedLangs.isNotEmpty &&
!wantedLangs.contains(stream.language.toLowerCase())) {
return false;
}
if (hasTag) {
final hit = stream.tags.any((t) => t.toLowerCase() == tagNeedle);
if (!hit) return false;
}
return true;
}
// Fast path: no tag filter and 0–1 languages → one Helix call.
if (!hasTag && wantedLangs.length <= 1) {
final lang = wantedLangs.isEmpty ? null : wantedLangs.first;
return gameId == null
? getTopStreams(cursor: cursor, language: lang)
: getStreamsUnderCategory(
gameId: gameId,
cursor: cursor,
language: lang,
);
}
// Sparse tags need a deeper Helix scan per request.
final effectiveMin = hasTag ? minCount.clamp(1, 100) : minCount;
final effectiveMax = hasTag ? (maxPages < 25 ? 25 : maxPages) : maxPages;
// When filtering by tag and exactly one language, still pass language to
// Helix so each page is denser in matches.
final helixLanguage =
wantedLangs.length == 1 ? wantedLangs.first : null;
final matched = <StreamTwitch>[];
String? pageCursor = cursor;
String? nextCursor;
var pages = 0;
while (matched.length < effectiveMin && pages < effectiveMax) {
final page = gameId == null
? await getTopStreams(cursor: pageCursor, language: helixLanguage)
: await getStreamsUnderCategory(
gameId: gameId,
cursor: pageCursor,
language: helixLanguage,
);
pages++;
nextCursor = page.pagination['cursor'];
for (final stream in page.data) {
if (matches(stream)) matched.add(stream);
}
if (nextCursor == null ||
nextCursor.isEmpty ||
page.data.isEmpty) {
nextCursor = null;
break;
}
pageCursor = nextCursor;
}
return StreamsTwitch(
matched,
nextCursor != null && nextCursor.isNotEmpty
? {'cursor': nextCursor}
: const {},
);
}
/// Deprecated name — use [getDirectoryStreams].
Future<StreamsTwitch> getStreamsMatchingLanguages({
required List<String> languages,
String? gameId,
String? cursor,
int minCount = 40,
int maxPages = 8,
}) =>
getDirectoryStreams(
languages: languages,
gameId: gameId,
cursor: cursor,
minCount: minCount,
maxPages: maxPages,
);
/// Returns a [StreamTwitch] object containing the stream info associated with the given [userLogin].
Future<StreamTwitch> getStream({required String userLogin}) async {
final data = await get<JsonMap>(
@ -623,131 +483,4 @@ class TwitchApi extends BaseApiClient {
return data['messages'] as JsonList;
}
/// Returns a list of broadcaster IDs for channels the authenticated user
/// moderates, following pagination so moderators of more than one page of
/// channels aren't silently truncated at the first 100.
Future<List<String>> getModeratedChannels({
required String id,
String? cursor,
}) async {
try {
// Default page size is 20; request the max to keep the request count low.
final queryParams = {'user_id': id, 'first': '100'};
if (cursor != null) queryParams['after'] = cursor;
final data = await get<JsonMap>(
'/moderation/channels',
queryParameters: queryParams,
);
final channels = data['data'] as JsonList;
final result = channels
.map((channel) => (channel as JsonMap)['broadcaster_id'] as String?)
.whereType<String>()
.toList();
final paginationCursor = data['pagination']['cursor'];
if (paginationCursor != null && channels.isNotEmpty) {
// Throttle between pages to stay under Twitch's rate limit, matching
// getUserBlockedList above.
await Future.delayed(const Duration(milliseconds: 150));
result.addAll(
await getModeratedChannels(id: id, cursor: paginationCursor),
);
}
return result;
} on ApiException catch (e) {
// Backstop: callers should skip this without moderator scopes, but a
// stale/partial token must not take down chat.
debugPrint('Failed to get moderated channels: $e');
return [];
}
}
/// Deletes a specific chat message.
Future<bool> deleteChatMessage({
required String broadcasterId,
required String moderatorId,
required String messageId,
}) async {
// Let ApiException propagate so the caller can surface Twitch's reason
// (e.g. you can't delete the broadcaster's or another moderator's message)
// instead of a generic failure.
await delete<dynamic>(
'/moderation/chat',
queryParameters: {
'broadcaster_id': broadcasterId,
'moderator_id': moderatorId,
'message_id': messageId,
},
);
return true;
}
/// Bans or times out a user from a channel.
///
/// The optional `duration` in seconds will timeout the user. If omitted, the user is banned.
/// The optional `reason` will be displayed to the banned user and other moderators.
Future<bool> banUser({
required String broadcasterId,
required String moderatorId,
required String userIdToBan,
int? duration,
String? reason,
}) async {
final requestBody = {
'data': {
'user_id': userIdToBan,
'duration': ?duration,
'reason': ?reason,
},
};
// Let ApiException propagate so the caller can surface Twitch's reason
// (e.g. the target is a moderator and can't be banned) instead of a
// generic failure.
final response = await post<JsonMap>(
'/moderation/bans',
data: requestBody,
queryParameters: {
'broadcaster_id': broadcasterId,
'moderator_id': moderatorId,
},
);
final data = response['data'] as JsonList;
return data.isNotEmpty;
}
/// Removes a ban or timeout for a user in a channel.
///
/// A timeout is a time-limited ban, so this lifts both. Lets ApiException
/// propagate (e.g. the user isn't currently banned) so the caller can show
/// Twitch's reason.
Future<bool> unbanUser({
required String broadcasterId,
required String moderatorId,
required String userId,
}) async {
await delete<dynamic>(
'/moderation/bans',
queryParameters: {
'broadcaster_id': broadcasterId,
'moderator_id': moderatorId,
'user_id': userId,
},
);
return true;
}
}
/// Result of a successful Twitch `/oauth2/validate` call.
class TwitchTokenInfo {
final List<String> scopes;
/// Present for user access tokens; omitted for app access tokens.
final String? userId;
const TwitchTokenInfo({required this.scopes, this.userId});
}

View file

@ -1,170 +1,53 @@
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/gql_chat_comment.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.
/// Twitch GQL API client for playback access tokens and VOD chat.
///
/// Uses the Twitch web client ID (not the app's registered client ID)
/// since the GQL endpoint requires it.
/// Uses the Twitch web client ID for playback tokens and the mobile
/// client ID for VOD chat (bypasses Client-Integrity checks).
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,
final body = {
'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': {
'isLive': true,
'login': login,
'isVod': false,
'vodID': '',
'playerType': 'site',
},
};
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,
}),
);
}
final response = await post<JsonMap>(
'/gql',
data: body,
headers: headers,
);
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,
@ -173,387 +56,58 @@ class TwitchGqlApi extends BaseApiClient {
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'
return 'https://usher.ttvnw.net/api/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'
'&supported_codecs=h264'
'&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.
/// Fetches VOD chat comments for the given [videoId].
///
/// 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,
/// Pass [contentOffsetSeconds] for initial fetch at a position, or
/// [cursor] for pagination from a previous response.
Future<GqlChatResponse> getVideoComments({
required String videoId,
int? contentOffsetSeconds,
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,
'Client-ID': twitchGqlMobileClientId,
};
final response = await post<dynamic>(
'/gql',
data: {
'operationName': operationName,
'variables': variables,
'extensions': {
'persistedQuery': {
'version': 1,
'sha256Hash': sha256Hash,
},
final variables = <String, dynamic>{
'videoID': videoId,
};
if (cursor != null) {
variables['cursor'] = cursor;
} else {
variables['contentOffsetSeconds'] = contentOffsetSeconds ?? 0;
}
final body = {
'operationName': 'VideoCommentsByOffsetOrCursor',
'variables': variables,
'extensions': {
'persistedQuery': {
'version': 1,
'sha256Hash':
'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a',
},
},
};
final response = await post<JsonMap>(
'/gql',
data: body,
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 [],
);
return GqlChatResponse.fromJson(response);
}
}

View file

@ -1,81 +0,0 @@
import 'dart:convert';
/// Helpers for Twitch `Client-Integrity` tokens (`v4.public.*` / `v4.local.*`).
class TwitchIntegrity {
const TwitchIntegrity._();
/// Decodes the JSON payload embedded in an integrity token, if present.
static Map<String, dynamic>? decodePayload(String token) {
final parts = token.split('.');
if (parts.length < 2) return null;
for (final part in parts.skip(1)) {
if (part.isEmpty) continue;
try {
final normalized = base64Url.normalize(part);
final decoded = utf8.decode(base64Url.decode(normalized));
final dynamic json = jsonDecode(decoded);
if (json is Map &&
(json.containsKey('is_bad_bot') ||
json.containsKey('client_id') ||
json.containsKey('device_id'))) {
return Map<String, dynamic>.from(json);
}
} catch (_) {
// Try the next segment — token layout varies by version.
}
}
return null;
}
/// Dio/`POST /integrity` tokens from non-browser clients are usually flagged.
static bool isBadBot(String token) {
final payload = decodePayload(token);
if (payload == null) return false;
final flag = payload['is_bad_bot'];
if (flag is bool) return flag;
if (flag is String) return flag.toLowerCase() == 'true';
return false;
}
/// Expiration in epoch millis from the payload, if present.
static int? expirationMs(String token) {
final payload = decodePayload(token);
if (payload == null) return null;
final exp = payload['exp'];
if (exp is int) {
// Already millis if large; seconds if typical JWT.
return exp > 100000000000 ? exp : exp * 1000;
}
if (exp is String) {
final asInt = int.tryParse(exp);
if (asInt != null) {
return asInt > 100000000000 ? asInt : asInt * 1000;
}
final dt = DateTime.tryParse(exp);
if (dt != null) return dt.millisecondsSinceEpoch;
}
return null;
}
/// Device id the token was minted for (`X-Device-Id` must match).
static String? deviceId(String token) {
final payload = decodePayload(token);
final id = payload?['device_id'];
return id is String && id.isNotEmpty ? id : null;
}
}
/// Credentials captured from a browser/WebView GQL request.
class GqlIntegrityCredentials {
final String integrityToken;
final String deviceId;
final int expirationMs;
const GqlIntegrityCredentials({
required this.integrityToken,
required this.deviceId,
required this.expirationMs,
});
}

View file

@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:frosty/main.dart';
import 'package:frosty/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';
@ -24,57 +23,8 @@ class UnauthorizedInterceptor extends Interceptor {
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(),
),
),
);
},
);
// For other requests, show login dialog and reject so the Future completes
_showLoginDialog();
handler.reject(err);
return;
}
@ -83,20 +33,25 @@ class UnauthorizedInterceptor extends Interceptor {
handler.next(err);
}
void _showAuthDialog({
required String title,
required String message,
required String primaryLabel,
required VoidCallback onPrimary,
}) {
void _showLoginDialog() {
// Use the global navigator key to show dialog without needing BuildContext
final context = navigatorKey.currentContext;
if (context == null) return;
// Use a flag to prevent multiple dialogs
if (_isDialogShowing) return;
_isDialogShowing = true;
// Determine if user is logged in but missing scopes vs completely logged out
final isLoggedIn = _authStore.isLoggedIn;
final title = isLoggedIn ? 'Missing permissions' : 'Session expired';
final message = isLoggedIn
? 'Your session is missing permissions. Please log in again to continue.'
: 'Your session has expired. Please log in again to continue.';
showDialog(
context: context,
barrierDismissible: false,
barrierDismissible: false, // User must choose an action
builder: (BuildContext dialogContext) {
return FrostyDialog(
title: title,
@ -105,22 +60,31 @@ class UnauthorizedInterceptor extends Interceptor {
TextButton(
onPressed: () {
_isDialogShowing = false;
Navigator.of(dialogContext).pop();
Navigator.of(dialogContext).pop(); // Close dialog
},
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
_isDialogShowing = false;
Navigator.of(dialogContext).pop();
onPrimary();
Navigator.of(dialogContext).pop(); // Close dialog
// Navigate to login WebView
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => Provider<AuthStore>.value(
value: _authStore,
child: const LoginWebView(),
),
),
);
},
child: Text(primaryLabel),
child: Text('Log in'),
),
],
);
},
).then((_) {
// Reset flag when dialog is dismissed
_isDialogShowing = false;
});
}
@ -133,11 +97,3 @@ class UnauthorizedInterceptor extends Interceptor {
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

@ -6,14 +6,11 @@ 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';
/// SharedPreferences key for the last-selected stream quality.
const kLastStreamQualityKey = 'last_stream_quality';
/// Legacy global key. Cleared on startup during SharedPreferences init.
const kLegacyLastStreamQualityKey = 'last_stream_quality';
/// Twitch GQL mobile client ID (bypasses Client-Integrity checks, used for VOD chat).
const twitchGqlMobileClientId = 'kd1unb4b3q4t58fwlpcbzcbnm76a8fp';
/// Twitch API client secret.
const secret = String.fromEnvironment('SECRET');

View file

@ -1,20 +1,16 @@
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';
@ -24,7 +20,6 @@ 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';
@ -39,25 +34,6 @@ 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();
@ -66,44 +42,17 @@ void main() async {
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 all uncaught "fatal" errors from the framework to Crashlytics
FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;
// 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.
// 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,
reason: error.runtimeType.toString(),
fatal: !_isNonFatalError(error),
);
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.
@ -167,10 +116,7 @@ void main() async {
);
// Create and initialize the authentication store
final authStore = AuthStore(
twitchApi: twitchApiService,
twitchGqlApi: twitchGqlApiService,
);
final authStore = AuthStore(twitchApi: twitchApiService);
// Add the auth interceptor to the Dio client after AuthStore creation
dioClient.interceptors.add(TwitchAuthInterceptor(authStore));

View file

@ -0,0 +1,137 @@
// Models for Twitch GQL VOD chat comments.
//
// Manually parsed (no codegen) because the GQL response uses camelCase
// and deeply nested edges/node patterns.
class GqlChatEmote {
final String emoteId;
const GqlChatEmote({required this.emoteId});
factory GqlChatEmote.fromJson(Map<String, dynamic> json) {
return GqlChatEmote(emoteId: json['emoteID'] as String);
}
}
class GqlChatFragment {
final String? text;
final GqlChatEmote? emote;
const GqlChatFragment({this.text, this.emote});
factory GqlChatFragment.fromJson(Map<String, dynamic> json) {
return GqlChatFragment(
text: json['text'] as String?,
emote: json['emote'] != null
? GqlChatEmote.fromJson(json['emote'] as Map<String, dynamic>)
: null,
);
}
}
class GqlChatBadge {
final String setId;
final String version;
const GqlChatBadge({required this.setId, required this.version});
factory GqlChatBadge.fromJson(Map<String, dynamic> json) {
return GqlChatBadge(
setId: json['setID'] as String,
version: json['version'] as String,
);
}
}
class GqlChatComment {
final String id;
final int contentOffsetSeconds;
final String? commenterLogin;
final String? commenterDisplayName;
final String? commenterId;
final String? userColor;
final List<GqlChatBadge> userBadges;
final List<GqlChatFragment> messageFragments;
const GqlChatComment({
required this.id,
required this.contentOffsetSeconds,
this.commenterLogin,
this.commenterDisplayName,
this.commenterId,
this.userColor,
required this.userBadges,
required this.messageFragments,
});
factory GqlChatComment.fromGqlNode(Map<String, dynamic> node) {
final commenter = node['commenter'] as Map<String, dynamic>?;
final message = node['message'] as Map<String, dynamic>;
return GqlChatComment(
id: node['id'] as String,
contentOffsetSeconds: node['contentOffsetSeconds'] as int,
commenterLogin: commenter?['login'] as String?,
commenterDisplayName: commenter?['displayName'] as String?,
commenterId: commenter?['id'] as String?,
userColor: message['userColor'] as String?,
userBadges: (message['userBadges'] as List)
.map((b) => GqlChatBadge.fromJson(b as Map<String, dynamic>))
.toList(),
messageFragments: (message['fragments'] as List)
.map((f) => GqlChatFragment.fromJson(f as Map<String, dynamic>))
.toList(),
);
}
}
class GqlChatResponse {
final List<GqlChatComment> comments;
final bool hasNextPage;
final String? cursor;
const GqlChatResponse({
required this.comments,
required this.hasNextPage,
this.cursor,
});
factory GqlChatResponse.fromJson(Map<String, dynamic> json) {
// GQL returns 200 even on errors — check for errors array.
if (json['errors'] != null) {
final errors = json['errors'] as List;
final message = errors.isNotEmpty
? (errors.first as Map<String, dynamic>)['message'] as String?
: 'Unknown GQL error';
throw Exception('GQL error: $message');
}
final video = json['data']?['video'] as Map<String, dynamic>?;
if (video == null) {
return const GqlChatResponse(
comments: [],
hasNextPage: false,
);
}
final commentsData = video['comments'] as Map<String, dynamic>;
final edges = commentsData['edges'] as List;
final pageInfo = commentsData['pageInfo'] as Map<String, dynamic>;
final comments = edges
.map((edge) => GqlChatComment.fromGqlNode(
(edge as Map<String, dynamic>)['node'] as Map<String, dynamic>,
))
.toList();
final cursor = edges.isNotEmpty
? (edges.last as Map<String, dynamic>)['cursor'] as String?
: null;
return GqlChatResponse(
comments: comments,
hasNextPage: pageInfo['hasNextPage'] as bool,
cursor: cursor,
);
}
}

View file

@ -8,6 +8,7 @@ import 'package:frosty/cache_manager.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/models/badges.dart';
import 'package:frosty/models/emotes.dart';
import 'package:frosty/models/gql_chat_comment.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';
@ -56,11 +57,6 @@ class IRCMessage {
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,
@ -191,6 +187,7 @@ class IRCMessage {
span.add(const TextSpan(text: ' '));
}
final sourceChannelId = tags['source-room-id'] ?? tags['room-id'];
final sourceChannelUser = channelIdToUserTwitch?[sourceChannelId];
if (sourceChannelUser != null) {
final isCurrentChannel = sourceChannelId == currentChannelId;
@ -667,8 +664,6 @@ class IRCMessage {
bool showMessage = true,
Map<String, UserTwitch>? channelIdToUserTwitch,
TimestampType timestamp = TimestampType.disabled,
bool forceTimestamp = false,
bool showHistoricalTimestamps = false,
String? currentChannelId,
}) {
final emoteToObject = assetsStore.emoteToObject;
@ -679,19 +674,7 @@ class IRCMessage {
// 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);
_addTimestamp(span, style, timestamp);
_addHistoricalAndChannelBadges(
context,
span,
@ -700,6 +683,7 @@ class IRCMessage {
currentChannelId,
);
final isHistorical = tags['historical'] == '1';
_addUserBadges(
context,
span,
@ -978,31 +962,22 @@ 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 +991,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
? mappedTags['login']
: prefix.substring(0, bangIndex);
}
// tmi.twitch.tv means the message was sent by Twitch rather than a user, so will be irrelevant.
final String? user = splitMessage[0] == _twitchIrcServer
? mappedTags['login']
: splitMessage[0].substring(0, splitMessage[0].indexOf('!'));
// If there is an associated message, set it.
String? message;
@ -1069,9 +1026,9 @@ class IRCMessage {
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(
@ -1199,48 +1156,54 @@ class IRCMessage {
);
}
/// Unescapes an IRCv3 message-tag value.
/// Creates an [IRCMessage] from a GQL VOD chat comment.
///
/// 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;
/// Maps GQL fields to IRC tags so [generateSpan] works identically
/// for both live and VOD chat messages.
factory IRCMessage.fromGqlComment(GqlChatComment comment) {
final tags = <String, String>{
'display-name': comment.commenterDisplayName ?? 'Deleted User',
'user-id': comment.commenterId ?? '',
'id': comment.id,
'tmi-sent-ts': '${comment.contentOffsetSeconds * 1000}',
if (comment.userColor != null) 'color': comment.userColor!,
if (comment.userBadges.isNotEmpty)
'badges': comment.userBadges
.map((b) => '${b.setId}/${b.version}')
.join(','),
};
final buffer = StringBuffer();
for (var i = 0; i < value.length; i++) {
if (value[i] != '\\') {
buffer.write(value[i]);
continue;
// Reconstruct message text from fragments.
final messageText = comment.messageFragments
.map((f) => f.text ?? '')
.join();
// Build local emotes map from fragments that have Twitch emotes.
// generateSpan() will match these by name in the split words.
final localEmotes = <String, Emote>{};
for (final fragment in comment.messageFragments) {
if (fragment.emote != null && fragment.text != null) {
localEmotes[fragment.text!] = Emote(
name: fragment.text!,
zeroWidth: false,
url:
'$_twitchEmoteBaseUrl/${fragment.emote!.emoteId}$_emoteModeSuffix',
type: EmoteType.twitchSub,
);
}
// 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();
return IRCMessage(
raw: '',
command: Command.privateMessage,
tags: tags,
user: comment.commenterLogin,
localEmotes: localEmotes.isNotEmpty ? localEmotes : null,
message: messageText,
split: messageText.split(' ').where((w) => w.isNotEmpty).toList(),
action: false,
mention: false,
);
}
factory IRCMessage.createNotice({
@ -1304,9 +1267,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 +1,24 @@
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,
);
}
}
class PlaybackAccessToken {
final String value;
final String signature;
const PlaybackAccessToken({
required this.value,
required this.signature,
});
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',
);
}
return PlaybackAccessToken(
value: data['value'] as String,
signature: data['signature'] 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);
@ -48,20 +38,6 @@ class StreamsTwitch {
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,
);
}
factory StreamsTwitch.fromJson(Map<String, dynamic> json) =>
_$StreamsTwitchFromJson(json);
}

View file

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

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();
}

60
lib/models/video.dart Normal file
View file

@ -0,0 +1,60 @@
import 'package:json_annotation/json_annotation.dart';
part 'video.g.dart';
/// Object for Twitch VODs (past broadcasts).
@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
class VideoTwitch {
final String id;
final String? streamId;
final String userId;
final String userLogin;
final String userName;
final String title;
final String createdAt;
final String publishedAt;
final String duration;
final String thumbnailUrl;
final int viewCount;
const VideoTwitch(
this.id,
this.streamId,
this.userId,
this.userLogin,
this.userName,
this.title,
this.createdAt,
this.publishedAt,
this.duration,
this.thumbnailUrl,
this.viewCount,
);
factory VideoTwitch.fromJson(Map<String, dynamic> json) =>
_$VideoTwitchFromJson(json);
}
@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
class VideosTwitch {
final List<VideoTwitch> data;
final Map<String, String> pagination;
const VideosTwitch(this.data, this.pagination);
factory VideosTwitch.fromJson(Map<String, dynamic> json) =>
_$VideosTwitchFromJson(json);
}
/// Parses a Twitch duration string like "3h26m15s" into a [Duration].
Duration parseTwitchDuration(String duration) {
final regex = RegExp(r'(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?');
final match = regex.firstMatch(duration);
if (match == null) return Duration.zero;
final hours = int.tryParse(match.group(1) ?? '') ?? 0;
final minutes = int.tryParse(match.group(2) ?? '') ?? 0;
final seconds = int.tryParse(match.group(3) ?? '') ?? 0;
return Duration(hours: hours, minutes: minutes, seconds: seconds);
}

28
lib/models/video.g.dart Normal file
View file

@ -0,0 +1,28 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'video.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
VideoTwitch _$VideoTwitchFromJson(Map<String, dynamic> json) => VideoTwitch(
json['id'] as String,
json['stream_id'] as String?,
json['user_id'] as String,
json['user_login'] as String,
json['user_name'] as String,
json['title'] as String,
json['created_at'] as String,
json['published_at'] as String,
json['duration'] as String,
json['thumbnail_url'] as String,
(json['view_count'] as num).toInt(),
);
VideosTwitch _$VideosTwitchFromJson(Map<String, dynamic> json) => VideosTwitch(
(json['data'] as List<dynamic>)
.map((e) => VideoTwitch.fromJson(e as Map<String, dynamic>))
.toList(),
Map<String, String>.from(json['pagination'] as Map),
);

View file

@ -6,7 +6,6 @@ import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_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';
@ -47,10 +46,11 @@ 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);
// PiP drag state - essential only
double _pipDragDistance = 0;
bool _isPipDragging = false;
bool _isInPipTriggerZone = false;
bool _isInPipTriggerZone =
false; // Track when in trigger zone for haptic feedback
// Divider drag state for synchronizing animation
bool _isDividerDragging = false;
@ -83,7 +83,6 @@ class _VideoChatState extends State<VideoChat>
? NativeVideoStore(
userLogin: widget.userLogin,
userId: widget.userId,
displayName: widget.userName,
twitchApi: context.twitchApi,
twitchGqlApi: context.twitchGqlApi,
authStore: context.authStore,
@ -112,10 +111,12 @@ class _VideoChatState extends State<VideoChat>
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
);
// Drive the ValueNotifier from the spring-back animation so the
// AnimatedBuilder rebuilds without a full setState.
// Attach listener to the controller (not the derived animation) so it
// survives _springBackAnimation being reassigned in _animateSpringBack.
_animationController.addListener(() {
_pipDragDistance.value = _springBackAnimation.value;
setState(() {
_pipDragDistance = _springBackAnimation.value;
});
});
// Register as observer for app lifecycle events
@ -132,58 +133,58 @@ class _VideoChatState extends State<VideoChat>
}
void _handlePipDragStart(DragStartDetails details) {
if (_videoStore.isInPipMode || _videoStore.paused || _videoStore.loading) {
return;
}
// Disable drag gesture when already in PiP mode or video is not playing
if (_videoStore.isInPipMode || _videoStore.paused) return;
_animationController.stop();
_pipDragDistance.value = 0;
_isInPipTriggerZone = false;
_animationController.stop(); // Stop any ongoing animation
setState(() {
_isPipDragging = true;
_pipDragDistance = 0;
_isInPipTriggerZone = false;
});
}
void _handlePipDragUpdate(DragUpdateDetails details) {
if (!_isPipDragging ||
_videoStore.isInPipMode ||
_videoStore.paused ||
_videoStore.loading) {
if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) {
return;
}
// Update the ValueNotifier directly — only the ListenableBuilder rebuilds,
// not the entire channel scaffold.
_pipDragDistance.value =
(_pipDragDistance.value + details.delta.dy).clamp(0, _pipMaxDragDistance);
setState(() {
_pipDragDistance += details.delta.dy;
_pipDragDistance = _pipDragDistance.clamp(0, _pipMaxDragDistance);
final wasInTriggerZone = _isInPipTriggerZone;
_isInPipTriggerZone = _pipDragDistance.value >= _pipTriggerDistance;
// Check if we've entered or exited the trigger zone for haptic feedback
final wasInTriggerZone = _isInPipTriggerZone;
_isInPipTriggerZone = _pipDragDistance >= _pipTriggerDistance;
if (!wasInTriggerZone && _isInPipTriggerZone) {
HapticFeedback.mediumImpact();
} else if (wasInTriggerZone && !_isInPipTriggerZone) {
HapticFeedback.lightImpact();
}
// Provide haptic feedback when entering the trigger zone
if (!wasInTriggerZone && _isInPipTriggerZone) {
HapticFeedback.mediumImpact(); // Entering trigger zone
}
// Provide subtle haptic feedback when exiting the trigger zone
else if (wasInTriggerZone && !_isInPipTriggerZone) {
HapticFeedback.lightImpact(); // Exiting trigger zone
}
});
}
void _handlePipDragEnd(DragEndDetails details) {
if (!_isPipDragging ||
_videoStore.isInPipMode ||
_videoStore.paused ||
_videoStore.loading) {
if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) {
return;
}
final velocity = details.velocity.pixelsPerSecond.dy;
final shouldTriggerPip =
_pipDragDistance.value >= _pipTriggerDistance || velocity > 600;
_pipDragDistance >= _pipTriggerDistance ||
velocity > 600; // Simple velocity threshold
if (shouldTriggerPip) {
// Simple haptic feedback on success
HapticFeedback.mediumImpact();
_videoStore.requestPictureInPicture();
_resetDragState();
} else {
// Animate back to original position
_animateSpringBack();
}
}
@ -194,16 +195,16 @@ class _VideoChatState extends State<VideoChat>
}
void _resetDragState() {
_pipDragDistance.value = 0;
_isInPipTriggerZone = false;
setState(() {
_isPipDragging = false;
_pipDragDistance = 0;
_isInPipTriggerZone = false;
});
}
void _animateSpringBack() {
_springBackAnimation =
Tween<double>(begin: _pipDragDistance.value, end: 0).animate(
_springBackAnimation = Tween<double>(begin: _pipDragDistance, end: 0)
.animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
);
@ -241,13 +242,13 @@ class _VideoChatState extends State<VideoChat>
? 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,
return AnimatedBuilder(
animation: _animationController,
child: wrappedChild,
builder: (context, animatedChild) {
final currentDragDistance = _pipDragDistance.value;
final currentDragDistance = _isPipDragging
? _pipDragDistance
: _springBackAnimation.value;
final scaleFactor =
1.0 - (currentDragDistance / _pipMaxDragDistance * 0.1);
@ -268,13 +269,11 @@ class _VideoChatState extends State<VideoChat>
if (!_videoStore.isInPipMode)
Positioned.fill(
child: IgnorePointer(
ignoring:
!(_isPipDragging && currentDragDistance > 0),
ignoring: !(_isPipDragging && _pipDragDistance > 0),
child: AnimatedOpacity(
opacity:
(_isPipDragging && currentDragDistance > 0)
? 1.0
: 0.0,
opacity: (_isPipDragging && _pipDragDistance > 0)
? 1.0
: 0.0,
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
child: Container(
@ -312,14 +311,7 @@ class _VideoChatState extends State<VideoChat>
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.
key: _videoKey,
nativeVideoStore: _videoStore,
)
? NativeVideo(nativeVideoStore: _videoStore)
: Video(
key: _videoKey,
videoStore: _videoStore as VideoStore,
@ -344,15 +336,17 @@ class _VideoChatState extends State<VideoChat>
},
child: Observer(
builder: (_) {
if (_videoStore.loading) {
return const SizedBox.shrink();
}
final videoOverlay = VideoOverlay(
videoStore: _videoStore,
chatStore: _chatStore,
settingsStore: settingsStore,
);
if (_videoStore.paused ||
_videoStore.streamInfo == null ||
_videoStore.loading) {
if (_videoStore.paused || _videoStore.streamInfo == null) {
return videoOverlay;
}
@ -372,26 +366,18 @@ class _VideoChatState extends State<VideoChat>
),
);
final video = 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(
final video = Observer(
builder: (context) {
return Stack(
children: [
player,
Positioned.fill(
child: AnimatedOpacity(
opacity: _videoStore.loading ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
child: const IgnorePointer(
child: ColoredBox(
color: Colors.black,
child: Center(
child: SizedBox(
@ -407,14 +393,10 @@ class _VideoChatState extends State<VideoChat>
),
),
),
),
),
Observer(
builder: (_) => _videoStore.settingsStore.showOverlay
? overlay
: const SizedBox.shrink(),
),
],
if (_videoStore.settingsStore.showOverlay) overlay,
],
);
},
);
final chat = Observer(
@ -747,14 +729,6 @@ class _VideoChatState extends State<VideoChat>
aspectRatio: 16 / 9,
child: SizedBox(),
),
ChannelInfoHeader(
videoStore: _videoStore,
authStore: context.authStore,
userId: widget.userId,
displayName: widget.userName,
isInSharedChatMode:
_chatStore.isInSharedChatMode,
),
],
Expanded(child: chat),
],
@ -780,16 +754,9 @@ class _VideoChatState extends State<VideoChat>
// 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,
);
@ -808,7 +775,6 @@ class _VideoChatState extends State<VideoChat>
_videoStore.dispose();
_animationController.dispose();
_pipDragDistance.dispose();
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,

View file

@ -1,5 +1,4 @@
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';
@ -12,10 +11,6 @@ import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/frosty_page_view.dart';
import 'package:frosty/widgets/frosty_scrollbar.dart';
/// ~2x default to keep richer message widgets (emotes, badges, replies) built
/// ahead of fast scroll-back without holding the entire history.
const _chatCacheExtent = ScrollCacheExtent.pixels(500.0);
class Chat extends StatelessWidget {
final ChatStore chatStore;
final EdgeInsetsGeometry? listPadding;
@ -86,59 +81,25 @@ class Chat extends StatelessWidget {
? 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,
),
child: Observer(
builder: (context) {
return _isMerged
? _buildMergedList(
scrollController,
bottomPadding,
)
: _buildNormalList(
scrollController,
bottomPadding,
);
},
),
return FrostyScrollbar(
controller: scrollController,
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top,
bottom:
chatStore.bottomBarHeight + bottomPadding,
),
child: Observer(
builder: (context) {
return _isMerged
? _buildMergedList(
scrollController,
bottomPadding,
)
: _buildNormalList(
scrollController,
bottomPadding,
);
},
),
);
},
@ -179,25 +140,18 @@ class Chat extends StatelessWidget {
return ListView.builder(
reverse: true,
padding: (listPadding ?? EdgeInsets.zero).add(
EdgeInsets.only(bottom: chatStore.bottomBarHeight + bottomPadding),
EdgeInsets.only(
bottom: chatStore.bottomBarHeight + bottomPadding,
),
),
addAutomaticKeepAlives: false,
scrollCacheExtent: _chatCacheExtent,
controller: scrollController,
itemCount: chatStore.renderMessages.length,
itemBuilder: (context, index) {
final ircMessage = chatStore
.renderMessages[chatStore.renderMessages.length - 1 - index];
// Key by message identity so the list reconciles by message rather
// than by slot. This keeps each message's element (and its gesture
// recognizers) bound to the same message when new messages arrive
// mid-interaction, fixing long-press landing on the wrong message.
return ChatMessage(
key: ObjectKey(ircMessage),
ircMessage: ircMessage,
chatStore: chatStore,
);
},
itemBuilder: (context, index) => ChatMessage(
ircMessage: chatStore.renderMessages[
chatStore.renderMessages.length - 1 - index],
chatStore: chatStore,
),
);
}
@ -206,22 +160,24 @@ class Chat extends StatelessWidget {
double bottomPadding,
) {
final mergedMessages = chatTabsStore!.mergedMessages;
final channelIdToUserTwitch = chatTabsStore!.mergedChannelIdToUserTwitch;
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),
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];
final merged =
mergedMessages[mergedMessages.length - 1 - index];
return ChatMessage(
key: ObjectKey(merged.ircMessage),
ircMessage: merged.ircMessage,
chatStore: merged.chatStore,
inputChatStore: chatStore,
@ -255,7 +211,8 @@ class Chat extends StatelessWidget {
right: 4,
bottom:
chatStore.bottomBarHeight +
(chatStore.assetsStore.showEmoteMenu || isHorizontalLandscape
(chatStore.assetsStore.showEmoteMenu ||
isHorizontalLandscape
? 0
: MediaQuery.of(context).padding.bottom),
),
@ -279,13 +236,17 @@ class Chat extends StatelessWidget {
? null
: ElevatedButton.icon(
onPressed: onResume,
icon: const Icon(Icons.arrow_downward_rounded),
icon: const Icon(
Icons.arrow_downward_rounded,
),
label: Text(
bufferCount > 0
? '$bufferCount new ${bufferCount == 1 ? 'message' : 'messages'}'
: 'Resume scroll',
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
fontFeatures: [
FontFeature.tabularFigures(),
],
),
),
),
@ -298,78 +259,76 @@ class Chat extends StatelessWidget {
}
Widget _buildEmoteMenu(BuildContext context) {
final menuHeight = context.screenHeight / (context.isPortrait ? 3 : 2);
return AnimatedContainer(
curve: Curves.ease,
duration: const Duration(milliseconds: 200),
height: chatStore.assetsStore.showEmoteMenu ? menuHeight : 0,
height: chatStore.assetsStore.showEmoteMenu
? context.screenHeight / (context.isPortrait ? 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(
children: [
const Divider(),
Expanded(
child: FrostyPageView(
headers: [
'Recent',
if (chatStore.settings.showTwitchEmotes) 'Twitch',
if (chatStore.settings.show7TVEmotes) '7TV',
if (chatStore.settings.showBTTVEmotes) 'BTTV',
if (chatStore.settings.showFFZEmotes) 'FFZ',
],
tabActions: {
if (chatStore.assetsStore.recentEmotes.isNotEmpty)
0: IconButton(
onPressed: () {
chatStore.assetsStore.recentEmotes.clear();
chatStore.updateNotification(
'Recent emotes cleared',
);
},
icon: const Icon(Icons.delete_outline_rounded),
tooltip: 'Clear recent emotes',
iconSize: 20,
),
},
children: [
RecentEmotesPanel(chatStore: chatStore),
if (chatStore.settings.showTwitchEmotes)
EmoteMenuPanel(
chatStore: chatStore,
twitchEmotes: chatStore
.assetsStore
.userEmoteSectionToEmotes,
),
...[
if (chatStore.settings.show7TVEmotes)
chatStore.assetsStore.sevenTVEmotes,
if (chatStore.settings.showBTTVEmotes)
chatStore.assetsStore.bttvEmotes,
if (chatStore.settings.showFFZEmotes)
chatStore.assetsStore.ffzEmotes,
].map(
(emotes) => EmoteMenuPanel(
chatStore: chatStore,
emotes: emotes,
child: Column(
children: [
const Divider(),
Expanded(
child: FrostyPageView(
headers: [
'Recent',
if (chatStore.settings.showTwitchEmotes)
'Twitch',
if (chatStore.settings.show7TVEmotes) '7TV',
if (chatStore.settings.showBTTVEmotes) 'BTTV',
if (chatStore.settings.showFFZEmotes) 'FFZ',
],
tabActions: {
if (chatStore
.assetsStore
.recentEmotes
.isNotEmpty)
0: IconButton(
onPressed: () {
chatStore.assetsStore.recentEmotes.clear();
chatStore.updateNotification(
'Recent emotes cleared',
);
},
icon: const Icon(
Icons.delete_outline_rounded,
),
tooltip: 'Clear recent emotes',
iconSize: 20,
),
],
),
},
children: [
RecentEmotesPanel(chatStore: chatStore),
if (chatStore.settings.showTwitchEmotes)
EmoteMenuPanel(
chatStore: chatStore,
twitchEmotes: chatStore
.assetsStore
.userEmoteSectionToEmotes,
),
...[
if (chatStore.settings.show7TVEmotes)
chatStore.assetsStore.sevenTVEmotes,
if (chatStore.settings.showBTTVEmotes)
chatStore.assetsStore.bttvEmotes,
if (chatStore.settings.showFFZEmotes)
chatStore.assetsStore.ffzEmotes,
].map(
(emotes) => EmoteMenuPanel(
chatStore: chatStore,
emotes: emotes,
),
),
],
),
],
),
),
],
),
)
: null,

View file

@ -0,0 +1,18 @@
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:mobx/mobx.dart';
/// Minimal interface for rendering chat messages.
///
/// Implemented by both [ChatStore] (live chat) and [VodChatStore] (VOD replay)
/// so that [ChatMessage] can render messages from either source.
abstract class ChatRenderContext {
ChatAssetsStore get assetsStore;
AuthStore get auth;
SettingsStore get settings;
String get channelId;
ObservableSet<String> get revealedMessageIds;
void revealMessage(String id);
void updateNotification(String message);
}

View file

@ -10,6 +10,7 @@ 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/channel/vod/vod_list.dart';
import 'package:frosty/screens/settings/settings.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart';
@ -371,6 +372,20 @@ class _ChatDetailsState extends State<ChatDetails> {
),
),
),
ListTile(
leading: const Icon(Icons.video_library_rounded),
title: const Text('Past broadcasts'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => VodList(
userId: widget.chatStore.channelId,
displayName: widget.chatStore.displayName,
),
),
),
),
ListTile(
leading: const Icon(Icons.add_comment_rounded),
title: const Text('Add chat'),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,9 +6,9 @@ import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/models/irc.dart';
import 'package:frosty/models/user.dart';
import 'package:frosty/screens/channel/chat/chat_render_context.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart';
import 'package:frosty/screens/channel/chat/widgets/moderation_actions.dart';
import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart';
@ -16,9 +16,8 @@ import 'package:provider/provider.dart';
class ChatMessage extends StatelessWidget {
final IRCMessage ircMessage;
final ChatStore chatStore;
final ChatRenderContext chatStore;
final bool isModal;
final bool forceTimestamp;
final bool showReplyHeader;
final bool isInReplyThread;
@ -41,7 +40,6 @@ class ChatMessage extends StatelessWidget {
required this.ircMessage,
required this.chatStore,
this.isModal = false,
this.forceTimestamp = false,
this.showReplyHeader = true,
this.isInReplyThread = false,
this.overrideChannelIdToUserTwitch,
@ -54,6 +52,9 @@ class ChatMessage extends StatelessWidget {
// Ignore if the message is a recent message in the modal bottom sheet.
if (isModal) return;
// User modal requires live ChatStore (recent messages, blocking, etc.).
if (chatStore is! ChatStore) return;
// Ignore if long-pressing own username.
if (ircMessage.user == null ||
ircMessage.user == chatStore.auth.user.details?.login) {
@ -64,11 +65,10 @@ class ChatMessage extends StatelessWidget {
isScrollControlled: true,
context: context,
builder: (context) => ChatUserModal(
chatStore: chatStore,
chatStore: chatStore as ChatStore,
username: ircMessage.user!,
userId: ircMessage.tags['user-id']!,
displayName: ircMessage.tags['display-name']!,
onActivateSourceTab: onActivateSourceTab,
),
);
}
@ -77,6 +77,9 @@ class ChatMessage extends StatelessWidget {
// Ignore if the message is a recent message in the modal bottom sheet.
if (isModal) return;
// User modal requires live ChatStore.
if (chatStore is! ChatStore) return;
final twitchApi = context.read<TwitchApi>();
twitchApi.getUser(userLogin: nickname).then((user) {
if (context.mounted) {
@ -84,11 +87,10 @@ class ChatMessage extends StatelessWidget {
isScrollControlled: true,
context: context,
builder: (context) => ChatUserModal(
chatStore: chatStore,
chatStore: chatStore as ChatStore,
username: user.login,
userId: user.id,
displayName: user.displayName,
onActivateSourceTab: onActivateSourceTab,
),
);
}
@ -96,7 +98,7 @@ class ChatMessage extends StatelessWidget {
}
/// The store to use for notifications and emote menu in merged mode.
ChatStore get _effectiveInputStore => inputChatStore ?? chatStore;
ChatRenderContext get _effectiveInputStore => inputChatStore ?? chatStore;
Future<void> copyMessage() async {
await Clipboard.setData(ClipboardData(text: ircMessage.message ?? ''));
@ -107,14 +109,8 @@ class ChatMessage extends StatelessWidget {
void onLongPressMessage(BuildContext context, TextStyle defaultTextStyle) {
HapticFeedback.lightImpact();
// Allow the full menu for live messages and for ones marked deleted/cleared
// by a moderation action — those keep their tags (user-id, id), so a
// moderator can still act on them (e.g. remove a timeout). Everything else
// (notices, system messages) just copies.
if (ircMessage.command != Command.privateMessage &&
ircMessage.command != Command.userState &&
ircMessage.command != Command.clearChat &&
ircMessage.command != Command.clearMessage) {
ircMessage.command != Command.userState) {
copyMessage();
return;
}
@ -137,11 +133,7 @@ class ChatMessage extends StatelessWidget {
badgeScale: chatStore.settings.badgeScale,
launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType,
forceTimestamp: forceTimestamp,
showHistoricalTimestamps:
chatStore.settings.showHistoricalTimestamps,
channelIdToUserTwitch:
overrideChannelIdToUserTwitch ??
channelIdToUserTwitch: overrideChannelIdToUserTwitch ??
chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId:
overrideCurrentChannelId ?? chatStore.channelId,
@ -159,61 +151,58 @@ class ChatMessage extends StatelessWidget {
leading: const Icon(Icons.copy),
title: const Text('Copy message'),
),
ListTile(
onTap: () async {
await copyMessage();
if (chatStore is ChatStore) ...[
ListTile(
onTap: () async {
await copyMessage();
final liveChatStore = chatStore as ChatStore;
final effectiveChatDelay = chatStore.settings.effectiveChatDelay;
final effectiveChatDelay =
liveChatStore.settings.effectiveChatDelay;
final hasChatDelay =
chatStore.settings.showVideo && effectiveChatDelay > 0;
final hasChatDelay =
liveChatStore.settings.showVideo && effectiveChatDelay > 0;
if (hasChatDelay) {
_effectiveInputStore.updateNotification(
'Chatting is disabled due to message delay (${effectiveChatDelay.toInt()}s)',
);
if (hasChatDelay) {
_effectiveInputStore.updateNotification(
'Chatting is disabled due to message delay (${effectiveChatDelay.toInt()}s)',
);
if (context.mounted) {
Navigator.pop(context);
}
return;
}
// In merged mode, switch to the source tab so the paste
// targets the correct channel's composer.
onActivateSourceTab?.call();
// Paste the copied message into the text controller
liveChatStore.textController.text = ircMessage.message ?? '';
liveChatStore.safeRequestFocus();
if (context.mounted) {
Navigator.pop(context);
}
return;
}
// In merged mode, switch to the source tab so the paste
// targets the correct channel's composer.
onActivateSourceTab?.call();
// Paste the copied message into the text controller
chatStore.textController.text = ircMessage.message ?? '';
chatStore.safeRequestFocus();
if (context.mounted) {
Navigator.pop(context);
}
},
leading: const Icon(Icons.content_paste),
title: const Text('Copy message and paste'),
),
ListTile(
onTap: () {
// In merged mode, switch to the source tab so the reply
// targets the correct channel (reply-parent-msg-id is
// channel-scoped).
onActivateSourceTab?.call();
chatStore.replyingToMessage = ircMessage;
chatStore.safeRequestFocus();
Navigator.pop(context);
},
leading: const Icon(Icons.reply),
title: const Text('Reply to message'),
),
if (ircMessage.tags['user-id'] != null)
ModerationActions(
chatStore: chatStore,
targetUserId: ircMessage.tags['user-id']!,
targetName:
ircMessage.tags['display-name'] ?? ircMessage.user ?? '',
messageId: ircMessage.tags['id'],
},
leading: const Icon(Icons.content_paste),
title: const Text('Copy message and paste'),
),
ListTile(
onTap: () {
final liveChatStore = chatStore as ChatStore;
// In merged mode, switch to the source tab so the reply
// targets the correct channel (reply-parent-msg-id is
// channel-scoped).
onActivateSourceTab?.call();
liveChatStore.replyingToMessage = ircMessage;
liveChatStore.safeRequestFocus();
Navigator.pop(context);
},
leading: const Icon(Icons.reply),
title: const Text('Reply to message'),
),
],
],
),
);
@ -257,11 +246,7 @@ class ChatMessage extends StatelessWidget {
badgeScale: chatStore.settings.badgeScale,
launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType,
forceTimestamp: forceTimestamp,
showHistoricalTimestamps:
chatStore.settings.showHistoricalTimestamps,
channelIdToUserTwitch:
overrideChannelIdToUserTwitch ??
channelIdToUserTwitch: overrideChannelIdToUserTwitch ??
chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId:
overrideCurrentChannelId ?? chatStore.channelId,
@ -282,14 +267,14 @@ class ChatMessage extends StatelessWidget {
color: messageHeaderTextColor,
);
messageHeader = GestureDetector(
onTap: isModal
onTap: isModal || chatStore is! ChatStore
? null
: () => showModalBottomSheetWithProperFocus(
context: context,
builder: (context) {
return ReplyThread(
selectedMessage: ircMessage,
chatStore: chatStore,
chatStore: chatStore as ChatStore,
);
},
),
@ -416,12 +401,9 @@ class ChatMessage extends StatelessWidget {
},
launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType,
forceTimestamp: forceTimestamp,
showHistoricalTimestamps:
chatStore.settings.showHistoricalTimestamps,
channelIdToUserTwitch:
overrideChannelIdToUserTwitch ??
chatStore.assetsStore.channelIdToUserTwitch,
chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId:
overrideCurrentChannelId ?? chatStore.channelId,
),
@ -548,12 +530,9 @@ class ChatMessage extends StatelessWidget {
badgeScale: chatStore.settings.badgeScale,
launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType,
forceTimestamp: forceTimestamp,
showHistoricalTimestamps:
chatStore.settings.showHistoricalTimestamps,
channelIdToUserTwitch:
overrideChannelIdToUserTwitch ??
chatStore.assetsStore.channelIdToUserTwitch,
chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId:
overrideCurrentChannelId ?? chatStore.channelId,
),
@ -626,15 +605,6 @@ class ChatMessage extends StatelessWidget {
child: dividedMessage,
);
final shouldFade = chatStore.shouldFadeMessage(
sourceChannelId: ircMessage.sourceChannelId,
overrideCurrentChannelId: overrideCurrentChannelId,
);
final fadedMessage = shouldFade
? Opacity(opacity: 0.55, child: coloredMessage)
: coloredMessage;
final finalMessage = InkWell(
onTap: () {
FocusScope.of(context).unfocus();
@ -643,7 +613,7 @@ class ChatMessage extends StatelessWidget {
}
},
onLongPress: () => onLongPressMessage(context, defaultTextStyle),
child: fadedMessage,
child: coloredMessage,
);
return finalMessage;

View file

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

View file

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

View file

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

View file

@ -1,63 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
import 'package:frosty/screens/channel/video/video_player_interface.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/follow_button.dart';
/// Channel metadata + follow control shown under the player (portrait).
class ChannelInfoHeader extends StatelessWidget {
final VideoPlayerInterface videoStore;
final AuthStore authStore;
final String userId;
final String displayName;
final bool isInSharedChatMode;
const ChannelInfoHeader({
super.key,
required this.videoStore,
required this.authStore,
required this.userId,
required this.displayName,
this.isInSharedChatMode = false,
});
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).scaffoldBackgroundColor,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
child: Observer(
builder: (context) {
final streamInfo = videoStore.streamInfo;
final offline = streamInfo == null;
return Row(
spacing: 8,
children: [
Expanded(
child: StreamInfoBar(
streamInfo: streamInfo,
offlineChannelInfo: videoStore.offlineChannelInfo,
displayName: displayName,
isCompact: true,
isOffline: offline,
isInSharedChatMode: isInSharedChatMode,
showTextShadows: false,
tooltipTriggerMode: TooltipTriggerMode.longPress,
),
),
FollowButton(
authStore: authStore,
userId: userId,
displayName: displayName,
),
],
);
},
),
),
);
}
}

View file

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

View file

@ -31,71 +31,27 @@ class NativeVideo extends StatelessWidget {
key: ObjectKey(controller),
controller: controller,
),
if (controller != null && !nativeVideoStore.hasPlayedOnce && !isOffline)
const ColoredBox(
color: Colors.black,
child: SizedBox.expand(),
),
if (error != null)
Align(
alignment: const Alignment(0, 0.3),
Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline_rounded,
color: Colors.amber.withValues(alpha: 0.7),
size: 32,
),
const SizedBox(height: 10),
Text(
error,
style: const TextStyle(
color: Colors.white60,
fontSize: 13,
height: 1.4,
),
textAlign: TextAlign.center,
),
],
padding: const EdgeInsets.all(16),
child: Text(
error,
style: const TextStyle(color: Colors.white70),
textAlign: TextAlign.center,
),
),
),
if (isOffline)
const Center(
child: Icon(
Icons.tv_off_rounded,
color: Colors.white24,
size: 32,
),
),
if (nativeVideoStore.isAudioOnlyMode && !isOffline)
const ColoredBox(
color: Colors.black,
child: SizedBox.expand(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.headphones_rounded,
color: Colors.white24,
size: 32,
),
SizedBox(height: 8),
Text(
'Audio Only',
style: TextStyle(
color: Colors.white24,
fontSize: 13,
),
),
],
child: Text(
'Offline',
style: TextStyle(
color: Colors.white38,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
),
],
),

View file

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

File diff suppressed because it is too large Load diff

View file

@ -9,14 +9,6 @@ part of 'native_video_store.dart';
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
mixin _$NativeVideoStore on NativeVideoStoreBase, Store {
Computed<String>? _$streamQualityComputed;
@override
String get streamQuality => (_$streamQualityComputed ??= Computed<String>(
() => super.streamQuality,
name: 'NativeVideoStoreBase.streamQuality',
)).value;
late final _$_controllerAtom = Atom(
name: 'NativeVideoStoreBase._controller',
context: context,
@ -57,26 +49,6 @@ mixin _$NativeVideoStore on NativeVideoStoreBase, Store {
});
}
late final _$_blockingAdsAtom = Atom(
name: 'NativeVideoStoreBase._blockingAds',
context: context,
);
bool get blockingAds {
_$_blockingAdsAtom.reportRead();
return super._blockingAds;
}
@override
bool get _blockingAds => blockingAds;
@override
set _blockingAds(bool value) {
_$_blockingAdsAtom.reportWrite(value, super._blockingAds, () {
super._blockingAds = value;
});
}
late final _$_pausedAtom = Atom(
name: 'NativeVideoStoreBase._paused',
context: context,
@ -97,26 +69,6 @@ mixin _$NativeVideoStore on NativeVideoStoreBase, Store {
});
}
late final _$_hasPlayedOnceAtom = Atom(
name: 'NativeVideoStoreBase._hasPlayedOnce',
context: context,
);
bool get hasPlayedOnce {
_$_hasPlayedOnceAtom.reportRead();
return super._hasPlayedOnce;
}
@override
bool get _hasPlayedOnce => hasPlayedOnce;
@override
set _hasPlayedOnce(bool value) {
_$_hasPlayedOnceAtom.reportWrite(value, super._hasPlayedOnce, () {
super._hasPlayedOnce = value;
});
}
late final _$_overlayVisibleAtom = Atom(
name: 'NativeVideoStoreBase._overlayVisible',
context: context,
@ -201,30 +153,6 @@ mixin _$NativeVideoStore on NativeVideoStoreBase, Store {
);
}
late final _$_restrictedStreamQualitiesAtom = Atom(
name: 'NativeVideoStoreBase._restrictedStreamQualities',
context: context,
);
List<String> get restrictedStreamQualities {
_$_restrictedStreamQualitiesAtom.reportRead();
return super._restrictedStreamQualities;
}
@override
List<String> get _restrictedStreamQualities => restrictedStreamQualities;
@override
set _restrictedStreamQualities(List<String> value) {
_$_restrictedStreamQualitiesAtom.reportWrite(
value,
super._restrictedStreamQualities,
() {
super._restrictedStreamQualities = value;
},
);
}
late final _$_streamQualityIndexAtom = Atom(
name: 'NativeVideoStoreBase._streamQualityIndex',
context: context,
@ -285,26 +213,6 @@ mixin _$NativeVideoStore on NativeVideoStoreBase, Store {
});
}
late final _$_isAudioOnlyModeAtom = Atom(
name: 'NativeVideoStoreBase._isAudioOnlyMode',
context: context,
);
bool get isAudioOnlyMode {
_$_isAudioOnlyModeAtom.reportRead();
return super._isAudioOnlyMode;
}
@override
bool get _isAudioOnlyMode => isAudioOnlyMode;
@override
set _isAudioOnlyMode(bool value) {
_$_isAudioOnlyModeAtom.reportWrite(value, super._isAudioOnlyMode, () {
super._isAudioOnlyMode = value;
});
}
late final _$_errorAtom = Atom(
name: 'NativeVideoStoreBase._error',
context: context,
@ -335,6 +243,16 @@ mixin _$NativeVideoStore on NativeVideoStoreBase, Store {
return _$_initPlayerAsyncAction.run(() => super._initPlayer());
}
late final _$handleRefreshAsyncAction = AsyncAction(
'NativeVideoStoreBase.handleRefresh',
context: context,
);
@override
Future<void> handleRefresh() {
return _$handleRefreshAsyncAction.run(() => super.handleRefresh());
}
late final _$updateStreamQualitiesAsyncAction = AsyncAction(
'NativeVideoStoreBase.updateStreamQualities',
context: context,
@ -388,18 +306,6 @@ mixin _$NativeVideoStore on NativeVideoStoreBase, Store {
context: context,
);
@override
Future<void> _reloadCurrentController() {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
name: 'NativeVideoStoreBase._reloadCurrentController',
);
try {
return super._reloadCurrentController();
} finally {
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void handleVideoTap() {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
@ -424,18 +330,6 @@ mixin _$NativeVideoStore on NativeVideoStoreBase, Store {
}
}
@override
Future<void> handleRefresh() {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
name: 'NativeVideoStoreBase.handleRefresh',
);
try {
return super.handleRefresh();
} finally {
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void togglePictureInPicture() {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
@ -460,18 +354,6 @@ mixin _$NativeVideoStore on NativeVideoStoreBase, Store {
}
}
@override
void handleAndroidPipChanged(bool isInPip) {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
name: 'NativeVideoStoreBase.handleAndroidPipChanged',
);
try {
return super.handleAndroidPipChanged(isInPip);
} finally {
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void dispose() {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
@ -484,34 +366,10 @@ mixin _$NativeVideoStore on NativeVideoStoreBase, Store {
}
}
@override
void _resetPlayerStateForChatOnly() {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
name: 'NativeVideoStoreBase._resetPlayerStateForChatOnly',
);
try {
return super._resetPlayerStateForChatOnly();
} finally {
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void _initFreshController() {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
name: 'NativeVideoStoreBase._initFreshController',
);
try {
return super._initFreshController();
} finally {
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''
streamQuality: ${streamQuality}
''';
}
}

View file

@ -1,27 +0,0 @@
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
/// Whether [NativeVideoStore] should auto-refresh after a mid-stream player
/// error instead of pinning the Reload overlay.
///
/// ExoPlayer's "Source error" (screen-off / network switch) arrives without a
/// status code, so callers must not rely on 4xx alone. [mediaServicesWereReset]
/// has its own dispose+rebuild path and is excluded here.
bool shouldAutoRecoverFromPlayerError({
required bool refreshCapReached,
String? errorCode,
}) {
if (refreshCapReached) return false;
if (errorCode == 'mediaServicesWereReset') return false;
return true;
}
/// Delay before an automatic refresh for [statusCode].
///
/// Token/CDN 4xx can refresh immediately; network/source failures get a short
/// pause so the interface can come back after a switch or screen wake.
Duration playerErrorRetryDelay({int? statusCode}) {
if (statusCode != null && statusCode >= 400 && statusCode < 500) {
return Duration.zero;
}
return VideoTimingConstants.initRetryDelay;
}

View file

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

View file

@ -90,7 +90,7 @@ class VideoOverlay extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionHeader(
'Quality',
'Stream quality',
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
isFirst: true,
),
@ -99,35 +99,26 @@ class VideoOverlay extends StatelessWidget {
builder: (context) => ListView(
shrinkWrap: true,
primary: false,
children: [
...videoStore.availableStreamQualities.map(
(quality) => ListTile(
trailing: videoStore.streamQuality == quality
? const Icon(Icons.check_rounded)
: null,
title: Text(quality),
onTap: () {
videoStore.setStreamQuality(quality);
SharedPreferences.getInstance().then(
(prefs) => prefs.setString(
lastStreamQualityKey(videoStore.userLogin),
quality,
),
);
Navigator.pop(context);
},
),
),
// Qualities the broadcaster offers only to subscribers
// (from the playback token's restricted_bitrates).
...videoStore.restrictedStreamQualities.map(
(quality) => ListTile(
enabled: false,
title: Text(quality),
subtitle: const Text('Subscribers only'),
),
),
],
children: videoStore.availableStreamQualities
.map(
(quality) => ListTile(
trailing: videoStore.streamQuality == quality
? const Icon(Icons.check_rounded)
: null,
title: Text(quality),
onTap: () {
videoStore.setStreamQuality(quality);
SharedPreferences.getInstance().then(
(prefs) => prefs.setString(
kLastStreamQualityKey,
quality,
),
);
Navigator.pop(context);
},
),
)
.toList(),
),
),
),
@ -322,25 +313,6 @@ class VideoOverlay extends StatelessWidget {
height: 80, // Covers bottom area around controls
child: Container(decoration: bottomGradient),
),
if (videoStore.isBlockingAds)
Positioned(
top: 8,
left: 8,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.8),
),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 5),
child: Text(
'Blocking ads',
style: TextStyle(color: Colors.white, fontSize: 12),
),
),
),
),
),
// Content
Stack(
children: [
@ -379,30 +351,29 @@ class VideoOverlay extends StatelessWidget {
videoSettingsButton,
],
),
if (!videoStore.loading)
Center(
child: Tooltip(
message: videoStore.paused ? 'Play' : 'Pause',
preferBelow: false,
child: IconButton(
iconSize: 56,
icon: Icon(
videoStore.paused
? Icons.play_arrow_rounded
: Icons.pause_rounded,
color: surfaceColor,
shadows: [
Shadow(
offset: const Offset(0, 3),
blurRadius: 8,
color: Colors.black.withValues(alpha: 0.6),
),
],
),
onPressed: videoStore.handlePausePlay,
Center(
child: Tooltip(
message: videoStore.paused ? 'Play' : 'Pause',
preferBelow: false,
child: IconButton(
iconSize: 56,
icon: Icon(
videoStore.paused
? Icons.play_arrow_rounded
: Icons.pause_rounded,
color: surfaceColor,
shadows: [
Shadow(
offset: const Offset(0, 3),
blurRadius: 8,
color: Colors.black.withValues(alpha: 0.6),
),
],
),
onPressed: videoStore.handlePausePlay,
),
),
),
Align(
alignment: Alignment.bottomLeft,
child: Row(

View file

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

File diff suppressed because it is too large Load diff

View file

@ -278,30 +278,6 @@ mixin _$VideoStore on VideoStoreBase, Store {
}
}
@override
void handleAndroidPipChanged(bool isInPip) {
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
name: 'VideoStoreBase.handleAndroidPipChanged',
);
try {
return super.handleAndroidPipChanged(isInPip);
} finally {
_$VideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void _setPipActive(bool isInPip) {
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
name: 'VideoStoreBase._setPipActive',
);
try {
return super._setPipActive(isInPip);
} finally {
_$VideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void handleAppResume() {
final _$actionInfo = _$VideoStoreBaseActionController.startAction(

View file

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

View file

@ -0,0 +1,284 @@
import 'dart:async';
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:frosty/models/gql_chat_comment.dart';
import 'package:frosty/models/irc.dart';
import 'package:frosty/screens/channel/chat/chat_render_context.dart';
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:mobx/mobx.dart';
part 'vod_chat_store.g.dart';
class VodChatStore = VodChatStoreBase with _$VodChatStore;
abstract class VodChatStoreBase with Store implements ChatRenderContext {
/// The total maximum amount of messages in chat.
static const _messageLimit = 5000;
/// The maximum amount of messages to render when autoscroll is enabled.
static const _renderMessageLimit = 100;
@override
final ChatAssetsStore assetsStore;
@override
final AuthStore auth;
@override
final SettingsStore settings;
@override
final String channelId;
@override
final revealedMessageIds = ObservableSet<String>();
final TwitchGqlApi twitchGqlApi;
final String videoId;
/// The scroll controller that controls auto-scroll and resume-scroll behavior.
final scrollController = ScrollController();
/// The amount of messages to free (remove) when the [_messageLimit] is reached.
final _messagesToRemove = (_messageLimit * 0.2).toInt();
/// The periodic timer used for batching chat message re-renders.
Timer? _messageBufferTimer;
/// The list of chat messages to add once autoscroll is resumed.
final messageBuffer = ObservableList<IRCMessage>();
/// Timer used for dismissing the notification.
Timer? _notificationTimer;
/// Fetched GQL comments waiting to be advanced based on playback position.
final _commentBuffer = Queue<GqlChatComment>();
/// Cursor for fetching the next page of comments.
String? _nextCursor;
/// Whether we're currently fetching comments from the API.
bool _isFetching = false;
/// The last rendered content offset to avoid duplicates.
int _lastRenderedOffset = -1;
/// A notification message to display above the chat.
@readonly
String? _notification;
/// The list of chat messages to render and display.
@readonly
var _messages = ObservableList<IRCMessage>();
/// If the chat should automatically scroll/jump to the latest message.
@readonly
var _autoScroll = true;
/// Current playback position in seconds.
@observable
int playbackPosition = 0;
/// Whether the store has been initialized.
@readonly
var _isInitialized = false;
/// The list of chat messages that should be rendered. Used to prevent jank when resuming scroll.
@computed
List<IRCMessage> get renderMessages {
// If autoscroll is disabled, render ALL messages in chat.
// The second condition is to prevent an out of index error with sublist.
if (!_autoScroll || _messages.length < _renderMessageLimit) {
return _messages;
}
// When autoscroll is enabled, only show the first [_renderMessageLimit] messages.
return _messages.sublist(_messages.length - _renderMessageLimit);
}
VodChatStoreBase({
required this.assetsStore,
required this.auth,
required this.settings,
required this.channelId,
required this.twitchGqlApi,
required this.videoId,
});
Future<void> init() async {
await Future.wait([
assetsStore.init().then((_) => _loadAssets()),
_fetchComments(contentOffsetSeconds: 0),
]);
_startMessageBufferTimer();
_setupScrollListener();
_isInitialized = true;
}
Future<void> _loadAssets() async {
await assetsStore.assetsFuture(
channelId: channelId,
headers: auth.headersTwitch,
onEmoteError: (error) {
debugPrint(error.toString());
return <dynamic>[];
},
onBadgeError: (error) {
debugPrint(error.toString());
return <dynamic>[];
},
showTwitchEmotes: settings.showTwitchEmotes,
showTwitchBadges: settings.showTwitchBadges,
show7TVEmotes: settings.show7TVEmotes,
showBTTVEmotes: settings.showBTTVEmotes,
showBTTVBadges: settings.showBTTVBadges,
showFFZEmotes: settings.showFFZEmotes,
showFFZBadges: settings.showFFZBadges,
);
}
void _startMessageBufferTimer() {
_messageBufferTimer = Timer.periodic(
const Duration(milliseconds: 200),
(_) => addMessages(),
);
}
void _setupScrollListener() {
scrollController.addListener(() {
if (!scrollController.hasClients) return;
final isAtBottom = scrollController.offset <= 0;
if (isAtBottom != _autoScroll) {
_autoScroll = isAtBottom;
if (_autoScroll) addMessages();
}
});
}
@action
void addMessages() {
if (!_autoScroll || messageBuffer.isEmpty) return;
_messages.addAll(messageBuffer);
messageBuffer.clear();
if (_messages.length > _messageLimit) {
_messages.removeRange(0, _messagesToRemove);
}
}
/// Re-enables [_autoScroll] and jumps to the latest message.
@action
void resumeScroll() {
_autoScroll = true;
scrollController.jumpTo(0);
WidgetsBinding.instance.addPostFrameCallback((_) {
scrollController.jumpTo(0);
});
}
@override
@action
void revealMessage(String id) {
revealedMessageIds.add(id);
}
@override
@action
void updateNotification(String message) {
_notificationTimer?.cancel();
_notification = message;
_notificationTimer = Timer(const Duration(seconds: 5), () {
_notification = null;
});
}
@action
void clearNotification() {
_notificationTimer?.cancel();
_notification = null;
}
/// Called from the JS channel with the current video position.
@action
void updatePlaybackPosition(int offsetSeconds) {
final previousPosition = playbackPosition;
playbackPosition = offsetSeconds;
// Seek detection: if position jumped significantly, re-fetch.
final delta = offsetSeconds - previousPosition;
if (delta < -5 || delta > 10) {
handleSeek(offsetSeconds);
return;
}
// Advance comments from buffer to messageBuffer.
_advanceComments(offsetSeconds);
// Pre-fetch when running low.
if (_commentBuffer.length < 50 && !_isFetching && _nextCursor != null) {
_fetchComments(cursor: _nextCursor);
}
}
void _advanceComments(int offsetSeconds) {
while (_commentBuffer.isNotEmpty &&
_commentBuffer.first.contentOffsetSeconds <= offsetSeconds) {
final comment = _commentBuffer.removeFirst();
if (comment.contentOffsetSeconds > _lastRenderedOffset ||
_lastRenderedOffset == -1) {
_lastRenderedOffset = comment.contentOffsetSeconds;
messageBuffer.add(IRCMessage.fromGqlComment(comment));
}
}
}
/// Handles an explicit seek to [offsetSeconds], clearing state and re-fetching.
@action
Future<void> handleSeek(int offsetSeconds) async {
playbackPosition = offsetSeconds;
_commentBuffer.clear();
messageBuffer.clear();
_messages.clear();
_lastRenderedOffset = -1;
_nextCursor = null;
await _fetchComments(contentOffsetSeconds: offsetSeconds);
}
Future<void> _fetchComments({
int? contentOffsetSeconds,
String? cursor,
}) async {
if (_isFetching) return;
_isFetching = true;
try {
final response = await twitchGqlApi.getVideoComments(
videoId: videoId,
contentOffsetSeconds: contentOffsetSeconds,
cursor: cursor,
);
_commentBuffer.addAll(response.comments);
if (response.hasNextPage) {
_nextCursor = response.cursor;
} else {
_nextCursor = null;
}
} catch (e) {
debugPrint('Failed to fetch VOD comments: $e');
} finally {
_isFetching = false;
}
}
void dispose() {
_messageBufferTimer?.cancel();
_notificationTimer?.cancel();
scrollController.dispose();
}
}

View file

@ -0,0 +1,203 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'vod_chat_store.dart';
// **************************************************************************
// StoreGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
mixin _$VodChatStore on VodChatStoreBase, Store {
Computed<List<IRCMessage>>? _$renderMessagesComputed;
@override
List<IRCMessage> get renderMessages =>
(_$renderMessagesComputed ??= Computed<List<IRCMessage>>(
() => super.renderMessages,
name: 'VodChatStoreBase.renderMessages',
)).value;
late final _$_notificationAtom = Atom(
name: 'VodChatStoreBase._notification',
context: context,
);
String? get notification {
_$_notificationAtom.reportRead();
return super._notification;
}
@override
String? get _notification => notification;
@override
set _notification(String? value) {
_$_notificationAtom.reportWrite(value, super._notification, () {
super._notification = value;
});
}
late final _$_messagesAtom = Atom(
name: 'VodChatStoreBase._messages',
context: context,
);
ObservableList<IRCMessage> get messages {
_$_messagesAtom.reportRead();
return super._messages;
}
@override
ObservableList<IRCMessage> get _messages => messages;
@override
set _messages(ObservableList<IRCMessage> value) {
_$_messagesAtom.reportWrite(value, super._messages, () {
super._messages = value;
});
}
late final _$_autoScrollAtom = Atom(
name: 'VodChatStoreBase._autoScroll',
context: context,
);
bool get autoScroll {
_$_autoScrollAtom.reportRead();
return super._autoScroll;
}
@override
bool get _autoScroll => autoScroll;
@override
set _autoScroll(bool value) {
_$_autoScrollAtom.reportWrite(value, super._autoScroll, () {
super._autoScroll = value;
});
}
late final _$playbackPositionAtom = Atom(
name: 'VodChatStoreBase.playbackPosition',
context: context,
);
@override
int get playbackPosition {
_$playbackPositionAtom.reportRead();
return super.playbackPosition;
}
@override
set playbackPosition(int value) {
_$playbackPositionAtom.reportWrite(value, super.playbackPosition, () {
super.playbackPosition = value;
});
}
late final _$_isInitializedAtom = Atom(
name: 'VodChatStoreBase._isInitialized',
context: context,
);
bool get isInitialized {
_$_isInitializedAtom.reportRead();
return super._isInitialized;
}
@override
bool get _isInitialized => isInitialized;
@override
set _isInitialized(bool value) {
_$_isInitializedAtom.reportWrite(value, super._isInitialized, () {
super._isInitialized = value;
});
}
late final _$VodChatStoreBaseActionController = ActionController(
name: 'VodChatStoreBase',
context: context,
);
@override
void addMessages() {
final _$actionInfo = _$VodChatStoreBaseActionController.startAction(
name: 'VodChatStoreBase.addMessages',
);
try {
return super.addMessages();
} finally {
_$VodChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void resumeScroll() {
final _$actionInfo = _$VodChatStoreBaseActionController.startAction(
name: 'VodChatStoreBase.resumeScroll',
);
try {
return super.resumeScroll();
} finally {
_$VodChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void revealMessage(String id) {
final _$actionInfo = _$VodChatStoreBaseActionController.startAction(
name: 'VodChatStoreBase.revealMessage',
);
try {
return super.revealMessage(id);
} finally {
_$VodChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void updateNotification(String message) {
final _$actionInfo = _$VodChatStoreBaseActionController.startAction(
name: 'VodChatStoreBase.updateNotification',
);
try {
return super.updateNotification(message);
} finally {
_$VodChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void clearNotification() {
final _$actionInfo = _$VodChatStoreBaseActionController.startAction(
name: 'VodChatStoreBase.clearNotification',
);
try {
return super.clearNotification();
} finally {
_$VodChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void updatePlaybackPosition(int offsetSeconds) {
final _$actionInfo = _$VodChatStoreBaseActionController.startAction(
name: 'VodChatStoreBase.updatePlaybackPosition',
);
try {
return super.updatePlaybackPosition(offsetSeconds);
} finally {
_$VodChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''
playbackPosition: ${playbackPosition},
renderMessages: ${renderMessages}
''';
}
}

View file

@ -0,0 +1,73 @@
import 'package:flutter/foundation.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/models/video.dart';
import 'package:mobx/mobx.dart';
part 'vod_list_store.g.dart';
class VodListStore = VodListStoreBase with _$VodListStore;
abstract class VodListStoreBase with Store {
final TwitchApi twitchApi;
final String userId;
@readonly
var _videos = ObservableList<VideoTwitch>();
@readonly
var _isLoading = false;
@readonly
var _error = '';
String? _cursor;
bool _hasMore = true;
VodListStoreBase({
required this.twitchApi,
required this.userId,
});
@action
Future<void> fetchVideos() async {
_isLoading = true;
_error = '';
try {
final response = await twitchApi.getVideos(userId: userId);
_videos = response.data.asObservable();
_cursor = response.pagination['cursor'];
_hasMore = response.data.length >= 20;
} catch (e) {
_error = e.toString();
debugPrint('Failed to fetch VODs: $e');
} finally {
_isLoading = false;
}
}
@action
Future<void> fetchMore() async {
if (_isLoading || !_hasMore || _cursor == null) return;
_isLoading = true;
try {
final response = await twitchApi.getVideos(
userId: userId,
cursor: _cursor,
);
_videos.addAll(response.data);
_cursor = response.pagination['cursor'];
_hasMore = response.data.length >= 20;
} catch (e) {
debugPrint('Failed to fetch more VODs: $e');
} finally {
_isLoading = false;
}
}
@action
Future<void> refresh() async {
_cursor = null;
_hasMore = true;
await fetchVideos();
}
}

View file

@ -0,0 +1,108 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'vod_list_store.dart';
// **************************************************************************
// StoreGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
mixin _$VodListStore on VodListStoreBase, Store {
late final _$_videosAtom = Atom(
name: 'VodListStoreBase._videos',
context: context,
);
ObservableList<VideoTwitch> get videos {
_$_videosAtom.reportRead();
return super._videos;
}
@override
ObservableList<VideoTwitch> get _videos => videos;
@override
set _videos(ObservableList<VideoTwitch> value) {
_$_videosAtom.reportWrite(value, super._videos, () {
super._videos = value;
});
}
late final _$_isLoadingAtom = Atom(
name: 'VodListStoreBase._isLoading',
context: context,
);
bool get isLoading {
_$_isLoadingAtom.reportRead();
return super._isLoading;
}
@override
bool get _isLoading => isLoading;
@override
set _isLoading(bool value) {
_$_isLoadingAtom.reportWrite(value, super._isLoading, () {
super._isLoading = value;
});
}
late final _$_errorAtom = Atom(
name: 'VodListStoreBase._error',
context: context,
);
String get error {
_$_errorAtom.reportRead();
return super._error;
}
@override
String get _error => error;
@override
set _error(String value) {
_$_errorAtom.reportWrite(value, super._error, () {
super._error = value;
});
}
late final _$fetchVideosAsyncAction = AsyncAction(
'VodListStoreBase.fetchVideos',
context: context,
);
@override
Future<void> fetchVideos() {
return _$fetchVideosAsyncAction.run(() => super.fetchVideos());
}
late final _$fetchMoreAsyncAction = AsyncAction(
'VodListStoreBase.fetchMore',
context: context,
);
@override
Future<void> fetchMore() {
return _$fetchMoreAsyncAction.run(() => super.fetchMore());
}
late final _$refreshAsyncAction = AsyncAction(
'VodListStoreBase.refresh',
context: context,
);
@override
Future<void> refresh() {
return _$refreshAsyncAction.run(() => super.refresh());
}
@override
String toString() {
return '''
''';
}
}

View file

@ -0,0 +1,184 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/models/video.dart';
import 'package:frosty/screens/channel/vod/vod_player.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/frosty_cached_network_image.dart';
import 'package:frosty/widgets/skeleton_loader.dart';
/// A tappable card widget that displays a VOD's thumbnail and details.
class VodCard extends StatelessWidget {
final VideoTwitch video;
const VodCard({super.key, required this.video});
String _formatDuration(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
if (hours > 0) {
return '${hours}h${minutes.toString().padLeft(2, '0')}m${seconds.toString().padLeft(2, '0')}s';
}
return '${minutes}m${seconds.toString().padLeft(2, '0')}s';
}
String _formatRelativeDate(String createdAt) {
final date = DateTime.tryParse(createdAt);
if (date == null) return createdAt;
final now = DateTime.now().toUtc();
final difference = now.difference(date);
if (difference.inDays >= 365) {
final years = difference.inDays ~/ 365;
return '$years ${years == 1 ? 'year' : 'years'} ago';
} else if (difference.inDays >= 30) {
final months = difference.inDays ~/ 30;
return '$months ${months == 1 ? 'month' : 'months'} ago';
} else if (difference.inDays >= 1) {
return '${difference.inDays} ${difference.inDays == 1 ? 'day' : 'days'} ago';
} else if (difference.inHours >= 1) {
return '${difference.inHours} ${difference.inHours == 1 ? 'hour' : 'hours'} ago';
} else {
return '${difference.inMinutes} ${difference.inMinutes == 1 ? 'minute' : 'minutes'} ago';
}
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final thumbnailWidth = min((size.width * pixelRatio) ~/ 3, 1920);
final thumbnailHeight = min((thumbnailWidth * (9 / 16)).toInt(), 1080);
final duration = parseTwitchDuration(video.duration);
final thumbnailUrl = video.thumbnailUrl
.replaceFirst('%{width}', '$thumbnailWidth')
.replaceFirst('%{height}', '$thumbnailHeight');
final thumbnail = AspectRatio(
aspectRatio: 16 / 9,
child: thumbnailUrl.isEmpty
? Container(color: Colors.black12)
: FrostyCachedNetworkImage(
imageUrl: thumbnailUrl,
placeholder: (context, url) => const SkeletonLoader(
borderRadius: kCardBorderRadius,
),
useOldImageOnUrlChange: true,
),
);
final fontColor = DefaultTextStyle.of(context).style.color;
const subFontSize = 14.0;
final imageSection = ClipRRect(
borderRadius: kCardBorderRadius,
child: Stack(
alignment: AlignmentDirectional.bottomEnd,
children: [
thumbnail,
Container(
margin: const EdgeInsets.all(3),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: Colors.white.withValues(alpha: 0.08),
width: 0.5,
),
),
child: Text(
_formatDuration(duration),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: context.frostyColors.overlayOnSurface,
),
),
),
],
),
);
final vodInfoSection = Padding(
padding: const EdgeInsets.only(left: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 2,
children: [
Tooltip(
message: video.title.trim(),
preferBelow: false,
child: Text(
video.title.trim(),
overflow: TextOverflow.ellipsis,
maxLines: 2,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: fontColor,
),
),
),
Text(
_formatRelativeDate(video.createdAt),
style: TextStyle(
fontSize: subFontSize,
color: fontColor?.withValues(alpha: 0.8),
),
),
Text(
'${_formatViewCount(video.viewCount)} views',
style: TextStyle(
fontSize: subFontSize,
color: fontColor?.withValues(alpha: 0.8),
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
);
return InkWell(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => VodPlayer(
videoId: video.id,
channelId: video.userId,
title: video.title,
),
),
),
child: Padding(
padding: EdgeInsets.only(
top: 8,
bottom: 8,
left: 16 + MediaQuery.of(context).padding.left,
right: 16 + MediaQuery.of(context).padding.right,
),
child: Row(
children: [
Flexible(child: imageSection),
Flexible(flex: 2, child: vodInfoSection),
],
),
),
);
}
String _formatViewCount(int count) {
if (count >= 1000000) {
return '${(count / 1000000).toStringAsFixed(1)}M';
} else if (count >= 1000) {
return '${(count / 1000).toStringAsFixed(1)}K';
}
return count.toString();
}
}

View file

@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/vod/stores/vod_list_store.dart';
import 'package:frosty/screens/channel/vod/vod_card.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/alert_message.dart';
class VodList extends StatefulWidget {
final String userId;
final String displayName;
const VodList({
super.key,
required this.userId,
required this.displayName,
});
@override
State<VodList> createState() => _VodListState();
}
class _VodListState extends State<VodList> {
late final VodListStore _store = VodListStore(
twitchApi: context.twitchApi,
userId: widget.userId,
);
@override
void initState() {
super.initState();
_store.fetchVideos();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('${widget.displayName} — Past broadcasts')),
body: Observer(
builder: (context) {
if (_store.error.isNotEmpty && _store.videos.isEmpty) {
return Center(
child: AlertMessage(message: _store.error),
);
}
if (_store.videos.isEmpty && !_store.isLoading) {
return const Center(
child: AlertMessage(message: 'No past broadcasts found'),
);
}
return RefreshIndicator.adaptive(
onRefresh: _store.refresh,
child: NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is ScrollEndNotification &&
notification.metrics.extentAfter < 200) {
_store.fetchMore();
}
return false;
},
child: ListView.builder(
itemCount: _store.videos.length + (_store.isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (index == _store.videos.length) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator.adaptive(),
),
);
}
return VodCard(video: _store.videos[index]);
},
),
),
);
},
),
);
}
}

View file

@ -0,0 +1,282 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
import 'package:frosty/screens/channel/vod/stores/vod_chat_store.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/frosty_notification.dart';
import 'package:frosty/widgets/frosty_scrollbar.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
/// VOD player + chat replay screen.
///
/// Layout matches the live channel view [VideoChat]: no AppBar,
/// video on top (16:9), chat below (expanded).
class VodPlayer extends StatefulWidget {
final String videoId;
final String channelId;
final String title;
const VodPlayer({
super.key,
required this.videoId,
required this.channelId,
required this.title,
});
@override
State<VodPlayer> createState() => _VodPlayerState();
}
class _VodPlayerState extends State<VodPlayer> {
late final VodChatStore _vodChatStore;
late final WebViewController _webViewController;
String get _videoUrl =>
'https://player.twitch.tv/?video=v${widget.videoId}&parent=frosty&muted=false';
@override
void initState() {
super.initState();
_vodChatStore = VodChatStore(
assetsStore: ChatAssetsStore(
twitchApi: context.twitchApi,
bttvApi: context.bttvApi,
ffzApi: context.ffzApi,
sevenTVApi: context.sevenTVApi,
globalAssetsStore: context.globalAssetsStore,
),
auth: context.authStore,
settings: context.settingsStore,
channelId: widget.channelId,
twitchGqlApi: context.twitchGqlApi,
videoId: widget.videoId,
);
_vodChatStore.init();
_initWebView();
}
void _initWebView() {
// Platform-specific params for autoplay.
late final PlatformWebViewControllerCreationParams params;
if (WebViewPlatform.instance is WebKitWebViewPlatform) {
params = WebKitWebViewControllerCreationParams(
allowsInlineMediaPlayback: true,
mediaTypesRequiringUserAction: const <PlaybackMediaTypes>{},
);
} else {
params = const PlatformWebViewControllerCreationParams();
}
_webViewController =
WebViewController.fromPlatformCreationParams(params)
..setBackgroundColor(Colors.black)
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel(
'VodPosition',
onMessageReceived: (message) {
final text = message.message;
if (text.startsWith('seek:')) {
final seconds =
double.tryParse(text.substring(5))?.toInt() ?? 0;
_vodChatStore.handleSeek(seconds);
} else {
final seconds = double.tryParse(text)?.toInt() ?? 0;
_vodChatStore.updatePlaybackPosition(seconds);
}
},
)
..setNavigationDelegate(
NavigationDelegate(
onPageFinished: (url) async {
if (!url.contains('player.twitch.tv')) return;
await _injectPositionTracker();
},
),
);
// Android: disable user gesture requirement for autoplay.
if (_webViewController.platform is AndroidWebViewController) {
(_webViewController.platform as AndroidWebViewController)
.setMediaPlaybackRequiresUserGesture(false);
}
_webViewController.loadRequest(Uri.parse(_videoUrl));
}
Future<void> _injectPositionTracker() async {
// Wait for the video element to be available, then poll currentTime
// and listen for seek events.
await _webViewController.runJavaScript('''
(function() {
function tryAttach() {
var video = document.querySelector('video');
if (!video) {
setTimeout(tryAttach, 500);
return;
}
setInterval(function() {
VodPosition.postMessage(Math.floor(video.currentTime).toString());
}, 1000);
video.addEventListener('seeked', function() {
VodPosition.postMessage('seek:' + Math.floor(video.currentTime).toString());
});
}
tryAttach();
})();
''');
}
Widget _buildWebView() {
if (Platform.isAndroid &&
WebViewPlatform.instance is AndroidWebViewPlatform) {
return WebViewWidget.fromPlatformCreationParams(
params: AndroidWebViewWidgetCreationParams(
controller: _webViewController.platform,
displayWithHybridComposition:
!context.settingsStore.useTextureRendering,
),
);
}
return WebViewWidget(controller: _webViewController);
}
Widget _buildChatList() {
return Observer(
builder: (context) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: _vodChatStore.settings.messageScale.textScaler,
),
child: DefaultTextStyle(
style: context.defaultTextStyle.copyWith(
fontSize: _vodChatStore.settings.fontSize,
),
child: Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
FrostyScrollbar(
controller: _vodChatStore.scrollController,
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom,
),
child: Observer(
builder: (context) {
final messages = _vodChatStore.renderMessages;
return ListView.builder(
reverse: true,
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom,
),
addAutomaticKeepAlives: false,
controller: _vodChatStore.scrollController,
itemCount: messages.length,
itemBuilder: (context, index) => ChatMessage(
ircMessage:
messages[messages.length - 1 - index],
chatStore: _vodChatStore,
),
);
},
),
),
_buildResumeScrollButton(),
],
),
),
);
},
);
}
Widget _buildResumeScrollButton() {
return Builder(
builder: (context) {
return AnimatedPadding(
duration: const Duration(milliseconds: 200),
padding: EdgeInsets.only(
left: 4,
top: 4,
right: 4,
bottom: MediaQuery.of(context).padding.bottom,
),
child: Observer(
builder: (_) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: _vodChatStore.autoScroll
? null
: ElevatedButton.icon(
onPressed: _vodChatStore.resumeScroll,
icon: const Icon(Icons.arrow_downward_rounded),
label: Text(
_vodChatStore.messageBuffer.isNotEmpty
? '${_vodChatStore.messageBuffer.length} new ${_vodChatStore.messageBuffer.length == 1 ? 'message' : 'messages'}'
: 'Resume scroll',
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
),
),
);
},
),
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
bottom: false,
child: Column(
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: _buildWebView(),
),
Expanded(
child: Stack(
children: [
_buildChatList(),
Observer(
builder: (_) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: _vodChatStore.notification != null
? Align(
alignment: Alignment.topCenter,
child: FrostyNotification(
message: _vodChatStore.notification!,
onDismissed: _vodChatStore.clearNotification,
),
)
: null,
),
),
],
),
),
],
),
),
);
}
@override
void dispose() {
_vodChatStore.dispose();
super.dispose();
}
}

View file

@ -171,7 +171,7 @@ class _HomeState extends State<Home> {
),
selectedIcon: Icon(
Icons.favorite_rounded,
color: theme.colorScheme.primary,
color: theme.colorScheme.onSurface,
),
label: 'Following',
tooltip: 'Following',
@ -184,7 +184,7 @@ class _HomeState extends State<Home> {
),
selectedIcon: Icon(
Icons.arrow_upward_rounded,
color: theme.colorScheme.primary,
color: theme.colorScheme.onSurface,
),
label: 'Top',
tooltip: 'Top',
@ -197,7 +197,7 @@ class _HomeState extends State<Home> {
),
selectedIcon: Icon(
Icons.search_rounded,
color: theme.colorScheme.primary,
color: theme.colorScheme.onSurface,
),
label: 'Search',
tooltip: 'Search',

View file

@ -2,10 +2,8 @@ import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:frosty/screens/home/search/search_results_categories.dart';
import 'package:frosty/screens/home/search/search_results_channels.dart';
import 'package:frosty/screens/home/search/search_results_tags.dart';
import 'package:frosty/screens/home/search/search_store.dart';
import 'package:frosty/screens/home/stream_list/streams_list.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
@ -15,7 +13,7 @@ import 'package:frosty/widgets/frosty_scrollbar.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:provider/provider.dart';
/// The search section that contains search history and search results for channels and categories.
/// The search section that contians search history and search results for channels and categories.
class Search extends StatefulWidget {
// The scroll controller for handling scroll to top functionality.
final ScrollController scrollController;
@ -33,7 +31,6 @@ class _SearchState extends State<Search> {
late final _searchStore = SearchStore(
authStore: context.read<AuthStore>(),
twitchApi: context.read<TwitchApi>(),
twitchGqlApi: context.read<TwitchGqlApi>(),
);
@override
@ -46,6 +43,7 @@ class _SearchState extends State<Search> {
builder: (context) {
if (_searchStore.searchText.isEmpty) {
if (_searchStore.searchHistory.isEmpty) {
// Keep controller attached so borders stay in sync
return CustomScrollView(
controller: widget.scrollController,
slivers: [
@ -174,20 +172,6 @@ class _SearchState extends State<Search> {
),
),
SearchResultsCategories(searchStore: _searchStore),
SliverToBoxAdapter(
child: Builder(
builder: (context) => SectionHeader(
'Tags',
padding: EdgeInsets.fromLTRB(
16 + MediaQuery.of(context).padding.left,
8,
16 + MediaQuery.of(context).padding.right,
8,
),
),
),
),
SearchResultsTags(searchStore: _searchStore),
// Add padding for bottom navigation bar
const SliverBottomPadding(),
],
@ -221,7 +205,7 @@ class _SearchState extends State<Search> {
onChanged: _searchStore.onSearchTextChanged,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search_rounded),
hintText: 'Search channels, categories, or tags',
hintText: 'Search channels or categories',
suffixIcon:
_searchStore.textFieldFocusNode.hasFocus ||
_searchStore.searchText.isNotEmpty
@ -236,6 +220,35 @@ class _SearchState extends State<Search> {
}
_searchStore.textEditingController.clear();
_searchStore.onSearchTextChanged('');
// Ensure borders reset: scroll back to top and trigger listeners
if (widget.scrollController.hasClients) {
widget.scrollController.animateTo(
0,
duration: const Duration(
milliseconds: 150,
),
curve: Curves.easeOut,
);
}
// After the frame (when content swaps), re-evaluate border state
WidgetsBinding.instance.addPostFrameCallback((
_,
) {
if (widget.scrollController.hasClients) {
// Nudge listeners even if already at zero
final offset =
widget.scrollController.offset;
final target = (offset == 0)
? 0.01
: 0.0;
widget.scrollController.jumpTo(
(offset == 0) ? target : 0.0,
);
if (target == 0.01) {
widget.scrollController.jumpTo(0.0);
}
}
});
},
)
: null,

View file

@ -1,97 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/home/search/search_store.dart';
import 'package:frosty/screens/home/search/tag_streams.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/skeleton_loader.dart';
import 'package:mobx/mobx.dart';
class SearchResultsTags extends StatelessWidget {
final SearchStore searchStore;
const SearchResultsTags({super.key, required this.searchStore});
@override
Widget build(BuildContext context) {
return Observer(
builder: (context) {
final future = searchStore.tagFuture;
if (future == null) {
if (searchStore.isSearching) {
return _tagSkeletons();
}
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
switch (future.status) {
case FutureStatus.pending:
return _tagSkeletons();
case FutureStatus.rejected:
return const SliverToBoxAdapter(
child: SizedBox(
height: 100.0,
child: AlertMessage(
message: 'Unable to load tags',
vertical: true,
),
),
);
case FutureStatus.fulfilled:
final List<String> tags = future.result as List<String>? ?? const [];
if (tags.isEmpty) {
return const SliverToBoxAdapter(
child: SizedBox(
height: 100.0,
child: AlertMessage(
message: 'No matching tags',
vertical: true,
),
),
);
}
return SliverList.builder(
itemCount: tags.length,
itemBuilder: (context, index) {
final tag = tags[index];
return ListTile(
leading: const Icon(Icons.tag_rounded),
title: Text(tag),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(
name: TagStreams.routeName,
),
builder: (context) => TagStreams(tagName: tag),
),
);
},
);
},
);
}
},
);
}
Widget _tagSkeletons() {
return SliverList.builder(
itemCount: 4,
itemBuilder: (context, index) => ListTile(
leading: SkeletonLoader(
width: 24,
height: 24,
borderRadius: BorderRadius.circular(4),
),
title: SkeletonLoader(
height: 16,
borderRadius: BorderRadius.circular(4),
),
),
);
}
}

View file

@ -2,7 +2,6 @@ import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:frosty/models/category.dart';
import 'package:frosty/models/channel.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
@ -18,8 +17,6 @@ abstract class SearchStoreBase with Store {
final TwitchApi twitchApi;
final TwitchGqlApi twitchGqlApi;
final textEditingController = TextEditingController();
final textFieldFocusNode = FocusNode();
@ -43,20 +40,13 @@ abstract class SearchStoreBase with Store {
@readonly
ObservableFuture<CategoriesTwitch?>? _categoryFuture;
@readonly
ObservableFuture<List<String>>? _tagFuture;
SearchStoreBase({
required this.authStore,
required this.twitchApi,
required this.twitchGqlApi,
}) {
SearchStoreBase({required this.authStore, required this.twitchApi}) {
init();
}
@action
Future<void> init() async {
// Load SharedPreferences for local search history.
// Retrieve the instance that will allow us to retrieve local search history.
final prefs = await SharedPreferences.getInstance();
// Retrieve the search history from local storage. If it doesn't exist, use an empty list.
@ -85,7 +75,6 @@ abstract class SearchStoreBase with Store {
_isSearching = false;
_channelFuture = null;
_categoryFuture = null;
_tagFuture = null;
return;
}
@ -138,8 +127,6 @@ abstract class SearchStoreBase with Store {
}
return categories;
}).asObservable();
_tagFuture = twitchGqlApi.searchFreeformTags(query: query).asObservable();
}
/// Find a specific channel provided the [query].

View file

@ -109,26 +109,6 @@ mixin _$SearchStore on SearchStoreBase, Store {
});
}
late final _$_tagFutureAtom = Atom(
name: 'SearchStoreBase._tagFuture',
context: context,
);
ObservableFuture<List<String>>? get tagFuture {
_$_tagFutureAtom.reportRead();
return super._tagFuture;
}
@override
ObservableFuture<List<String>>? get _tagFuture => tagFuture;
@override
set _tagFuture(ObservableFuture<List<String>>? value) {
_$_tagFutureAtom.reportWrite(value, super._tagFuture, () {
super._tagFuture = value;
});
}
late final _$initAsyncAction = AsyncAction(
'SearchStoreBase.init',
context: context,

View file

@ -1,83 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/home/stream_list/stream_list_store.dart';
import 'package:frosty/screens/home/stream_list/streams_list.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/blurred_container.dart';
import 'package:frosty/widgets/stream_sort_button.dart';
import 'package:provider/provider.dart';
/// Streams matching a freeform tag (search → tap tag).
class TagStreams extends StatelessWidget {
static const routeName = 'TagStreams';
final String tagName;
const TagStreams({super.key, required this.tagName});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final settingsStore = context.watch<SettingsStore>();
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
extendBody: true,
extendBodyBehindAppBar: true,
body: Stack(
children: [
Positioned.fill(
child: Observer(
builder: (_) => StreamsList(
listType: ListType.tag,
freeformTag: tagName,
sortOrder: settingsStore.streamViewerSort,
showJumpButton: true,
),
),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: BlurredContainer(
gradientDirection: GradientDirection.up,
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top,
left: MediaQuery.of(context).padding.left,
right: MediaQuery.of(context).padding.right,
),
child: SizedBox(
height: kToolbarHeight,
child: Row(
children: [
IconButton(
tooltip: 'Back',
icon: Icon(Icons.adaptive.arrow_back_rounded),
onPressed: Navigator.of(context).pop,
),
Expanded(
child: Text(
tagName,
style: theme.textTheme.titleLarge,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Observer(
builder: (_) => StreamSortButton(
sortOrder: settingsStore.streamViewerSort,
onChanged: (order) =>
settingsStore.streamViewerSort = order,
),
),
],
),
),
),
),
],
),
);
}
}

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