Compare commits

..

1 commit

Author SHA1 Message Date
dependabot[bot]
3efd2783ac
Bump webview_flutter from 4.13.0 to 4.13.1
Bumps [webview_flutter](https://github.com/flutter/packages/tree/main/packages/webview_flutter) from 4.13.0 to 4.13.1.
- [Commits](https://github.com/flutter/packages/commits/webview_flutter-v4.13.1/packages/webview_flutter)

---
updated-dependencies:
- dependency-name: webview_flutter
  dependency-version: 4.13.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-11 23:56:06 +00:00
254 changed files with 7718 additions and 31499 deletions

View file

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

13
.claude/settings.json Normal file
View file

@ -0,0 +1,13 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true,
"feature-dev@claude-plugins-official": true,
"code-review@claude-plugins-official": true,
"commit-commands@claude-plugins-official": true,
"security-guidance@claude-plugins-official": true,
"typescript-lsp@claude-plugins-official": true,
"pr-review-toolkit@claude-plugins-official": true,
"code-simplifier@claude-plugins-official": true
},
"outputStyle": "Explanatory"
}

View file

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

View file

@ -1,2 +0,0 @@
CLIENT_ID=
SECRET=

View file

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

View file

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

216
.github/copilot-instructions.md vendored Normal file
View file

@ -0,0 +1,216 @@
# Copilot Instructions for Frosty
## Project Overview
Frosty is a cross-platform Flutter app for Twitch that supports third-party emotes (7TV, BTTV, FFZ). The architecture is built around **MobX state management** with strict code generation requirements and a **shared HTTP client pattern** for API efficiency.
## Critical Development Workflow
### Essential Commands
**Dependencies and Development:**
- `flutter pub get` - Install dependencies
- `flutter run` - Run the app on connected device/emulator
- `flutter build apk` - Build Android APK
- `flutter build ios` - Build iOS app
- `flutter analyze` - Run static analysis and lint checks
- `flutter test` - Run all tests
### Code Generation (REQUIRED)
All MobX stores and JSON models require code generation. **Always run after any MobX or model changes:**
```bash
dart run build_runner build --delete-conflicting-outputs
```
**Alternative commands:**
- `dart run build_runner build` - Generate code once
- `dart run build_runner watch` - Watch and regenerate code on changes
Generated `.g.dart` files are excluded from linting but must be committed to source control.
### Environment Setup
Twitch API requires client credentials via `--dart-define`:
```bash
flutter run --dart-define=clientId=YOUR_TWITCH_CLIENT_ID --dart-define=secret=YOUR_TWITCH_CLIENT_SECRET
```
## Architecture Patterns
### State Management (MobX)
- **Store Pattern**: Each feature has stores in `lib/screens/{feature}/stores/` (not at screen root)
- **Naming**: All stores end with `Store` suffix and have corresponding `.g.dart` files
- **Global Stores**:
- `AuthStore`, `SettingsStore` in `lib/screens/settings/stores/`
- `GlobalAssetsStore` (shared emote/badge cache) in `lib/stores/`
- **Dependency Injection**: Stores are provided via Provider in `main.dart` with shared HTTP client
### API Service Architecture
- **Shared HTTP Client**: Single Dio instance configured in `DioClient.createClient()` with:
- Connection pooling and keep-alive headers for efficiency
- Optimized timeouts: 8s connect, 15s receive, 10s send
- Global Twitch User-Agent header
- Simple logging in debug mode (not verbose to avoid log spam)
- **API Services**: `TwitchApi`, `BTTVApi`, `FFZApi`, `SevenTVApi` in `lib/apis/`
- **Authentication Interceptor**: `TwitchAuthInterceptor` automatically injects auth headers:
- Detects Twitch API URLs and adds Authorization + Client-Id headers
- Updates automatically when user authentication changes
- Eliminates manual header passing throughout the codebase
- **Error Handling**: `UnauthorizedInterceptor` catches 401 errors for token refresh
- **Service Pattern**: All API services extend `BaseApiClient` for consistent error handling
### Project Structure
**Key Directories:**
- `lib/screens/` - UI screens organized by feature (home, channel, settings, onboarding)
- Each screen has its own MobX stores in a `stores/` subdirectory
- `lib/models/` - Data models with JSON serialization (uses `.g.dart` files)
- `lib/apis/` - API services for Twitch, BTTV, FFZ, and 7TV
- `lib/widgets/` - Reusable UI components
- `lib/cache_manager.dart` - Custom cache management for images/media
- `lib/utils.dart` - Utility functions and helpers
- `lib/utils/` - Additional utility modules including context extensions
### Screen Organization
```
lib/screens/{feature}/
├── {feature}.dart # Main UI
├── stores/ # Feature stores directory
│ ├── {feature}_store.dart
│ └── {feature}_store.g.dart
└── subdirectories/ # Feature sub-components
```
**Main Application Flow:**
1. App starts in `main.dart` with Firebase initialization and dependency injection via Provider
2. Authentication handled by `AuthStore` using Twitch OAuth
3. Settings persisted via `SettingsStore` with SharedPreferences
4. API services inject common HTTP client for efficient connection reuse
### Model Generation
- **JSON Serialization**: Models use `@JsonSerializable()` with `.g.dart` companions
- **Emote Architecture**: Base `Emote` class with platform-specific factories (`Emote.fromTwitch()`, `Emote.fromBTTV()`, `Emote.fromFFZ()`, `Emote.from7TV()`)
## Key Integration Points
### Firebase Services
Initialized in `main.dart` with error handling:
- Crashlytics for uncaught errors
- Performance monitoring
- Analytics
### Chat System
- **IRC Connection**: WebSocket to Twitch IRC for real-time chat
- **Emote Loading**: Asynchronous third-party emote fetching (BTTV/FFZ/7TV)
- **Message Parsing**: Custom rendering with emote/badge support
- **Real-time IRC**: WebSocket connection to Twitch IRC with custom `IRCMessage` parsing
- **Third-party Emotes**: Asynchronous loading of BTTV, FFZ, and 7TV assets via dedicated APIs
- **Message Management**: 5000 message limit with 20% batch removal optimization for performance
- **Assets Store**: Separate `ChatAssetsStore` manages emotes, badges, and chat-related data
- **User interaction features**: Blocking, reporting, moderation capabilities
### Authentication Flow
- **OAuth**: Twitch OAuth via WebView in `AuthStore`
- **Token Storage**: FlutterSecureStorage for secure token persistence
- **Auto-refresh**: Token validation and refresh logic in `AuthStore`
- **Two-tier Token System**:
- Default app token for unauthenticated requests
- Optional user token stored in Flutter Secure Storage
- Automatic token validation on startup with fallback
- **WebView OAuth Flow**: Custom JavaScript injection in WebView for seamless Twitch login experience without opening external browsers
- **Secure Storage Cleanup**: First-run detection clears secure storage to handle Android/iOS uninstall scenarios where secure storage persists
## Code Style Enforcement
Analysis rules in `analysis_options.yaml`:
- Single quotes: `prefer_single_quotes`
- Package imports: `always_use_package_imports`
- Trailing commas: `require_trailing_commas`
- Final locals: `prefer_final_locals`
- Additional rules: `directives_ordering`, `avoid_void_async`, `always_declare_return_types`, `avoid_redundant_argument_values`, `unnecessary_parenthesis`
**Important**: `.g.dart` files are excluded from analysis but must be committed to source control.
## Common Patterns
### MobX Reactions
```dart
late final ReactionDisposer _disposeReaction;
_disposeReaction = reaction(
(_) => authStore.isLoggedIn,
(_) => _selectedIndex = 0,
);
```
### Settings Persistence
Auto-saving pattern in `main.dart`:
```dart
autorun((_) => prefs.setString('settings', jsonEncode(settingsStore)));
```
### Error Handling
API services use typed exceptions via `BaseApiClient` (`lib/apis/base_api_client.dart`):
- `ApiException`, `NetworkException`, `TimeoutException`, `ServerException`, `NotFoundException`, `UnauthorizedException`
## MobX Store Implementation
### Store Pattern
All stores follow `StoreBase with _$StoreName` pattern:
- Generate `.g.dart` files with `dart run build_runner build`
- Use `@observable`, `@action`, and `@computed` annotations
- Settings store uses `@JsonSerializable()` with automatic persistence via `autorun()`
### Authentication Headers
Generated as `@computed` properties that automatically update when tokens change.
### State Persistence
Settings automatically saved to SharedPreferences via MobX `autorun()` reaction.
## Custom Cache Management
- **Orphaned File Cleanup**: `CustomCacheManager.removeOrphanedCacheFiles()` runs on startup to remove files not in database
- **Optimized Settings**: 30-day stale period, 10,000 max objects for efficient image caching across the app
## Code Style Guidelines
- **Prefer modern Flutter/Dart features and type-safety**
- **Use consistent naming, comments, and styles matching the existing codebase**
- **Single quotes, trailing commas, and final locals per lint rules**
- **Package imports over relative imports**
- **Observer widgets only wrap necessary UI components for MobX optimization**
## Platform Support
- **iOS/Android**: Native platform features via plugins
- **Custom Cache**: `CustomCacheManager` for media caching
- **PiP Mode**: Custom implementation via git dependency
## Commit Convention
Commits use lowercase, descriptive messages without prefixes (e.g., `fix landscape bottom padding in chat bottom bar`, `add swipe pip gesture support`). Keep commits tightly scoped.
When working with this codebase, always consider MobX code generation requirements and the shared HTTP client pattern for API efficiency.

View file

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

View file

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

View file

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

View file

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

View file

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

7
.gitignore vendored
View file

@ -21,7 +21,7 @@ migrate_working_dir/
# The .vscode folder contains launch configuration and tasks you configure in # The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line # VS Code which you may wish to be included in version control, so this line
# is commented out by default. # is commented out by default.
# .vscode/ .vscode/
# Flutter/Dart/Pub related # Flutter/Dart/Pub related
**/doc/api/ **/doc/api/
@ -44,8 +44,6 @@ app.*.map.json
/android/app/debug /android/app/debug
/android/app/profile /android/app/profile
/android/app/release /android/app/release
# AGP 9 writes a problems report here even when the Gradle build dir is redirected
/android/build/
# Folder containing random testing files # Folder containing random testing files
random/ random/
@ -66,8 +64,5 @@ random/
# potentially sensitive review information # potentially sensitive review information
**/fastlane/metadata/review_information **/fastlane/metadata/review_information
# secrets
.env
# ai # ai
.claude/settings.local.json .claude/settings.local.json

View file

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

25
.vscode/launch.json vendored
View file

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

View file

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

128
CLAUDE.md Normal file
View file

@ -0,0 +1,128 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Workflow
**Plan Mode**: When in plan mode, interview thoroughly before coding:
- Ask detailed questions about technical implementation, UI/UX concerns, tradeoffs, and edge cases
- Continue asking questions until all requirements and non-obvious decisions are clear
- Don't begin implementation until all important details are resolved
**Pattern Consistency**: When implementing new code:
- Search the codebase to find existing usages and implementations
- Prefer following established patterns, styles, and practices for consistency
## Development Commands
**Common Commands:**
- `flutter pub get` - Install dependencies
- `flutter run --dart-define=clientId=YOUR_CLIENT_ID --dart-define=secret=YOUR_CLIENT_SECRET` - Run with Twitch API credentials
- `flutter analyze` - Run static analysis and lint checks
- `flutter test` - Run all tests
- `flutter build apk` / `flutter build ios` - Build release binaries
**Code Generation** (required after changing MobX stores or `@JsonSerializable` models):
- `dart run build_runner build` - Generate once
- `dart run build_runner build --delete-conflicting-outputs` - Force rebuild all
- `dart run build_runner watch` - Watch mode for development
## Architecture Overview
**State Management**: MobX with code generation. All stores end with `Store` and have corresponding `.g.dart` generated files.
**Key Directories**:
- `lib/screens/` - UI screens organized by feature (home, channel, settings, onboarding)
- Each screen has its own MobX stores in a `stores/` subdirectory
- `lib/models/` - Data models with JSON serialization (uses .g.dart files)
- `lib/apis/` - API services for Twitch, BTTV, FFZ, and 7TV
- `lib/widgets/` - Reusable UI components
- `lib/utils/` - Utility modules including context extensions
**Main Application Flow**:
1. App starts in `main.dart` with Firebase initialization and dependency injection via Provider
2. Authentication handled by `AuthStore` using Twitch OAuth
3. Settings persisted via `SettingsStore` with SharedPreferences and MobX `autorun()`
4. API services share a common Dio HTTP client for efficient connection reuse
**Global Stores** (injected via Provider in `main.dart`):
- `AuthStore` (`lib/screens/settings/stores/`) - Authentication state and token management
- `SettingsStore` (`lib/screens/settings/stores/`) - User preferences with automatic persistence via MobX `autorun()`
- `GlobalAssetsStore` (`lib/stores/`) - Shared cache for global emotes and badges across all chat tabs
**HTTP & Auth**: Centralized Dio client (`DioClient.createClient()`) with connection pooling and optimized timeouts. `TwitchAuthInterceptor` auto-injects auth headers for Twitch API URLs. `UnauthorizedInterceptor` catches 401 errors for token refresh. Two-tier token system: default app token + optional user token in Flutter Secure Storage.
## MobX Store Implementation
**Store Pattern**: All stores follow `StoreBase with _$StoreName` pattern:
```dart
class SomeStore = SomeStoreBase with _$SomeStore;
abstract class SomeStoreBase with Store {
@observable
var someValue = '';
@action
void updateValue(String value) => someValue = value;
@computed
String get derivedValue => someValue.toUpperCase();
}
```
**MobX Reactions** (for side effects and persistence):
```dart
// Auto-save settings whenever they change (in main.dart)
autorun((_) => prefs.setString('settings', jsonEncode(settingsStore)));
// React to specific state changes
reaction((_) => authStore.isLoggedIn, (_) => _selectedIndex = 0);
```
## Chat System Architecture
- **Real-time IRC**: WebSocket connection to Twitch IRC with custom `IRCMessage` parsing
- **Third-party Emotes**: Asynchronous loading of BTTV, FFZ, and 7TV assets via dedicated APIs
- **Message Management**: 5000 message limit with 20% batch removal optimization
- **Assets Store**: `ChatAssetsStore` manages channel-specific emotes/badges
## Additional Patterns
**Emote Architecture**: Base `Emote` class with platform-specific factories: `Emote.fromTwitch()`, `Emote.fromBTTV()`, `Emote.fromFFZ()`, `Emote.from7TV()`.
**Custom Cache Manager**: Uses 30-day stale period and 10k max objects. `CustomCacheManager.removeOrphanedCacheFiles()` runs on startup to clean files not in database.
**Secure Storage Cleanup**: First-run detection clears Flutter Secure Storage to handle Android/iOS uninstall edge case where secure storage persists but app data is wiped.
## API Architecture
**BaseApiClient Pattern**: All API services (`TwitchApi`, `BTTVApi`, `FFZApi`, `SevenTVApi`) extend `BaseApiClient` which provides:
- Generic GET/POST/PUT/DELETE methods with centralized error handling
- Automatic conversion of `DioException` to typed exceptions
**Exception Hierarchy** (in `lib/apis/base_api_client.dart`):
- `ApiException` - Base class for all API errors
- `NetworkException` - Connection errors (no internet)
- `TimeoutException` - Request timeouts
- `ServerException` - 5xx server errors
- `NotFoundException` - 404 errors
- `UnauthorizedException` - 401 errors (triggers token refresh)
## Code Style
**Lint Rules** (from `analysis_options.yaml`):
- `prefer_single_quotes`, `always_use_package_imports`, `require_trailing_commas`
- `prefer_final_locals`, `prefer_final_in_for_each`, `avoid_redundant_argument_values`
- `directives_ordering`, `avoid_void_async`, `always_declare_return_types`, `unnecessary_parenthesis`
**Other**:
- Generated `.g.dart` files must be committed alongside source changes
## Commit Convention
Commits use lowercase, descriptive messages without prefixes (e.g., `fix landscape bottom padding in chat bottom bar`, `add swipe pip gesture support`). 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

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

View file

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

View file

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

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

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

View file

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

View file

@ -13,8 +13,7 @@
android:label="Frosty" android:label="Frosty"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:allowBackup="false" android:allowBackup="false">
android:dataExtractionRules="@xml/data_extraction_rules">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

18
android/build.gradle Normal file
View file

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

View file

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

View file

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

View file

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

View file

@ -1 +1 @@
Frosty Frosty for Twitch

View file

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

View file

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

28
android/settings.gradle Normal file
View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,35 +0,0 @@
# Design: Twitch ad blocking (vaft) in Frosty fork
## Intent
Personal Forgejo fork of Frosty blocks Twitch live ads using [TwitchAdSolutions vaft](https://github.com/pixeltris/TwitchAdSolutions) ideas, adapted to Flutter.
## Upstream baseline
Frosty **did not** ship an adblocker. The native player only **detects** stitched ad breaks (`adBreakStream`) to avoid bad latency/stall recovery. Turbo/sub may get `hide_ads` via linked web GQL session — server-side, not client blocking.
## Strategy
### Native player (primary — this is the reliable path)
1. When `SettingsStore.blockTwitchAds` is true, request `PlaybackAccessToken` with `playerType=popout` (vaft `ForceAccessTokenPlayerType`) instead of `site`.
2. When the native plugin reports an ad break, resolve a backup Usher URL trying `embed` → `popout` → `autoplay`, preferring playlists whose media m3u8 does not contain `stitched`, then `loadUrl` that stream.
3. When the ad break ends, restore the primary popout URL.
4. Overlay shows “Blocking ads” while `_blockingAds` is true.
Implementation: `lib/apis/twitch_ad_solutions.dart`, `lib/apis/twitch_gql_api.dart`, `lib/screens/channel/video/native_video_store.dart`.
### WebView player (legacy, best-effort)
Inject vendored `assets/js/vaft.user.js` on page start/finish. Not true `@run-at document-start` on Android via `webview_flutter`, so native remains preferred. Enabling adblock in Settings forces native on.
## Settings
- `blockTwitchAds` (default `true`)
- Turning adblock on sets `useNativePlayer = true`
## Non-goals
- Upstream PR
- Full browser Worker prototype rewriting on native
- Third-party CDN proxies unless player-type swap proves insufficient

View file

@ -1,45 +0,0 @@
# Obtainium (Android)
Forgejo fork: [copilot/frosty](https://forgejo.fsociety00.cc/copilot/frosty) (**private**).
Releases: `.forgejo/workflows/release.yml` on runner `desktop-release` (tag `v*` or workflow_dispatch).
## Obtainium settings
| Setting | Value |
|---|---|
| Source | Forgejo |
| URL | `https://forgejo.fsociety00.cc/api/v1/repos/copilot/frosty/releases` |
| APK filter | `frosty-arm64-v8a-v.*\.apk` |
| Trim version | `v?([0-9]+\.[0-9]+\.[0-9]+)` |
| Match group | `1` |
Repo is private → add a Forgejo API token in Obtainium (same PAT as other Newas apps).
Quick add:
```text
obtainium://add/https://forgejo.fsociety00.cc/api/v1/repos/copilot/frosty/releases
```
## Signing / Play Store conflict
Package id stays `com.tommychow.frosty`. Play Store Frosty and this fork use **different signing keys**, so Obtainium cannot update stock → fork in place. Uninstall the Play build first, then install from Obtainium.
## CI secrets (Forgejo repo)
| Secret | Purpose |
|---|---|
| `RELEASE_KEYSTORE_BASE64` | Same pattern as Flow / Finamp / Jellyfin forks |
| `STORE_PASSWORD` | Keystore password |
| `KEY_ALIAS` | Key alias |
| `KEY_PASSWORD` | Key password |
| `CLIENT_ID` | Twitch Helix app client id (`--dart-define`) |
| `SECRET` | Twitch Helix app secret (`--dart-define`) |
## Cut a release
1. Bump `version:` in `pubspec.yaml` (`x.y.z+build`) if needed.
2. Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z`
or run **Actions → Release APK → Run workflow**.
3. Install / update via Obtainium once the release asset appears.

View file

@ -1,57 +0,0 @@
# Release — version, notes, tag, then stores
Cut a GitHub + Play + App Store release from `main`. Not a changelog dump. Not a docs rewrite.
This playbook lives in the repo and loads only when you open this workspace and ask to release. It is not a global skill.
Flow: **preflight → bump + notes → commit on `main` → tag → wait for workflows → publish the GitHub draft.**
The tag is the mechanical trigger. This file is the judgment pass around it.
## Preflight
Main CI green on current `HEAD`. Then `gh workflow run "Release dry-run"` on that `main` — the monthly heartbeat is not enough after a toolchain bump.
Click any pending Apple agreement first (`developer.apple.com/account`, then App Store Connect Business) or the iOS job 403s. There is no API for that. On Play, glance Console Home and **Policy and programs > App content** (`Needs attention`); a DDA banner or new declaration can block the AAB the same way.
## Version
Release commit on `main` is only `pubspec.yaml` (`x.y.z+build`, bump both) and `assets/release-notes.md`. Message: `release vX.Y.Z`.
Features since last tag → minor. Fixes only → patch. Build number +1.
Do not put `AGENTS.md` or other docs in that commit.
## Notes
Write for someone opening the app, not the commit log. Match the latest shipped version's voice (`Added` / `Improved` / `Fixed`). Skip CI, Flutter upgrades, codegen, and anything a user wouldn't notice; the GitHub compare link is the rest.
Keep about three most recent versions, consecutive. Drop from the oldest end. Never skip a version in the middle. GitHub releases keep the full history.
- Features that didn't exist last release. Improvements only for behavior that already shipped — gating a new feature (opt-in permissions, an Enable flow) is not an improvement.
- If you can't remember the user-facing symptom, it doesn't belong.
- `[Android]` / `[iOS]` on platform-only lines. Cluster them: one tagged line at the end of its section, two or more adjacent.
- Credit GitHub PR authors as `(by @user on GitHub)` on each bullet they landed. Not AI, not issue reporters. The GitHub release body strips ` on GitHub` as redundant.
Example shape (from v5.1.0): `Added a new experimental native video player option with quality selection…` not `Bumped better_native_video_player`.
## Tag
```bash
git commit -m "release vX.Y.Z"
git push origin main
git tag vX.Y.Z # lightweight, on that commit
git push origin vX.Y.Z # v*.*.* triggers both workflows
```
Do not `gh release create`. **Draft GitHub release** attaches `frosty-vX.Y.Z.apk` and fills the body from this version's section in `assets/release-notes.md`. **Build and release Frosty** uploads the Play AAB and submits iOS for review (`automatic_release: true`).
When the draft has the APK:
```bash
gh release edit vX.Y.Z --draft=false
```
The README latest-release badge stays stale until that publish. Store listing copy already points at GitHub — don't add per-version Play changelog files.
Do not push the tag until the release commit is on `origin/main`.

View file

@ -20,5 +20,7 @@
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1.0</string> <string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict> </dict>
</plist> </plist>

View file

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

View file

@ -1,27 +1,321 @@
PODS: PODS:
- advanced_in_app_review (0.0.1): - advanced_in_app_review (0.0.1):
- Flutter - Flutter
- app_links (7.0.0):
- Flutter
- device_info_plus (0.0.1):
- Flutter
- Firebase/CoreOnly (12.6.0):
- FirebaseCore (~> 12.6.0)
- Firebase/Crashlytics (12.6.0):
- Firebase/CoreOnly
- FirebaseCrashlytics (~> 12.6.0)
- Firebase/Performance (12.6.0):
- Firebase/CoreOnly
- FirebasePerformance (~> 12.6.0)
- firebase_analytics (12.1.0):
- firebase_core
- FirebaseAnalytics (= 12.6.0)
- Flutter
- firebase_core (4.3.0):
- Firebase/CoreOnly (= 12.6.0)
- Flutter
- firebase_crashlytics (5.0.6):
- Firebase/Crashlytics (= 12.6.0)
- firebase_core
- Flutter
- firebase_performance (0.11.1-3):
- Firebase/Performance (= 12.6.0)
- firebase_core
- Flutter
- FirebaseABTesting (12.6.0):
- FirebaseCore (~> 12.6.0)
- FirebaseAnalytics (12.6.0):
- FirebaseAnalytics/Default (= 12.6.0)
- FirebaseCore (~> 12.6.0)
- FirebaseInstallations (~> 12.6.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.6.0):
- FirebaseCore (~> 12.6.0)
- FirebaseInstallations (~> 12.6.0)
- GoogleAppMeasurement/Default (= 12.6.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- FirebaseCore (12.6.0):
- FirebaseCoreInternal (~> 12.6.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1)
- FirebaseCoreExtension (12.6.0):
- FirebaseCore (~> 12.6.0)
- FirebaseCoreInternal (12.6.0):
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- FirebaseCrashlytics (12.6.0):
- FirebaseCore (~> 12.6.0)
- FirebaseInstallations (~> 12.6.0)
- FirebaseRemoteConfigInterop (~> 12.6.0)
- FirebaseSessions (~> 12.6.0)
- GoogleDataTransport (~> 10.1)
- GoogleUtilities/Environment (~> 8.1)
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- FirebaseInstallations (12.6.0):
- FirebaseCore (~> 12.6.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- PromisesObjC (~> 2.4)
- FirebasePerformance (12.6.0):
- FirebaseCore (~> 12.6.0)
- FirebaseInstallations (~> 12.6.0)
- FirebaseRemoteConfig (~> 12.6.0)
- FirebaseSessions (~> 12.6.0)
- GoogleDataTransport (~> 10.1)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- nanopb (~> 3.30910.0)
- FirebaseRemoteConfig (12.6.0):
- FirebaseABTesting (~> 12.6.0)
- FirebaseCore (~> 12.6.0)
- FirebaseInstallations (~> 12.6.0)
- FirebaseRemoteConfigInterop (~> 12.6.0)
- FirebaseSharedSwift (~> 12.6.0)
- GoogleUtilities/Environment (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- FirebaseRemoteConfigInterop (12.6.0)
- FirebaseSessions (12.6.0):
- FirebaseCore (~> 12.6.0)
- FirebaseCoreExtension (~> 12.6.0)
- FirebaseInstallations (~> 12.6.0)
- GoogleDataTransport (~> 10.1)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- nanopb (~> 3.30910.0)
- PromisesSwift (~> 2.1)
- FirebaseSharedSwift (12.6.0)
- Flutter (1.0.0) - Flutter (1.0.0)
- flutter_native_splash (2.4.3):
- Flutter
- flutter_secure_storage (6.0.0): - flutter_secure_storage (6.0.0):
- Flutter - Flutter
- GoogleAdsOnDeviceConversion (3.2.0):
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/Core (12.6.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.6.0):
- GoogleAdsOnDeviceConversion (~> 3.2.0)
- GoogleAppMeasurement/Core (= 12.6.0)
- GoogleAppMeasurement/IdentitySupport (= 12.6.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.6.0):
- GoogleAppMeasurement/Core (= 12.6.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
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- PromisesObjC (2.4.0)
- PromisesSwift (2.4.0):
- PromisesObjC (= 2.4.0)
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
- wakelock_plus (0.0.1):
- Flutter
- webview_flutter_wkwebview (0.0.1):
- Flutter
- FlutterMacOS
DEPENDENCIES: DEPENDENCIES:
- advanced_in_app_review (from `.symlinks/plugins/advanced_in_app_review/ios`) - advanced_in_app_review (from `.symlinks/plugins/advanced_in_app_review/ios`)
- app_links (from `.symlinks/plugins/app_links/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`)
- firebase_performance (from `.symlinks/plugins/firebase_performance/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
- native_device_orientation (from `.symlinks/plugins/native_device_orientation/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
SPEC REPOS:
trunk:
- Firebase
- FirebaseABTesting
- FirebaseAnalytics
- FirebaseCore
- FirebaseCoreExtension
- FirebaseCoreInternal
- FirebaseCrashlytics
- FirebaseInstallations
- FirebasePerformance
- FirebaseRemoteConfig
- FirebaseRemoteConfigInterop
- FirebaseSessions
- FirebaseSharedSwift
- GoogleAdsOnDeviceConversion
- GoogleAppMeasurement
- GoogleDataTransport
- GoogleUtilities
- nanopb
- PromisesObjC
- PromisesSwift
EXTERNAL SOURCES: EXTERNAL SOURCES:
advanced_in_app_review: advanced_in_app_review:
:path: ".symlinks/plugins/advanced_in_app_review/ios" :path: ".symlinks/plugins/advanced_in_app_review/ios"
app_links:
:path: ".symlinks/plugins/app_links/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
firebase_analytics:
:path: ".symlinks/plugins/firebase_analytics/ios"
firebase_core:
:path: ".symlinks/plugins/firebase_core/ios"
firebase_crashlytics:
:path: ".symlinks/plugins/firebase_crashlytics/ios"
firebase_performance:
:path: ".symlinks/plugins/firebase_performance/ios"
Flutter: Flutter:
:path: Flutter :path: Flutter
flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_secure_storage: flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios" :path: ".symlinks/plugins/flutter_secure_storage/ios"
in_app_review:
:path: ".symlinks/plugins/in_app_review/ios"
native_device_orientation:
:path: ".symlinks/plugins/native_device_orientation/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
SPEC CHECKSUMS: SPEC CHECKSUMS:
advanced_in_app_review: daebe4c801525b756fe589de60fd327cb12f965c advanced_in_app_review: fe8fd50b8670cb3dbe67e4c26ca643f7aea8dbb8
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47 app_links: 6d01271b3907b0ee7325c5297c75d697c4226c4d
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
Firebase: a451a7b61536298fd5cbfe3a746fd40443a50679
firebase_analytics: 1c038d9e4edf25828a8985c0be4efcb84cdae32a
firebase_core: 7ca5e04fc97329a2245376eba53c5bed113ca21e
firebase_crashlytics: eb4e3214716bff15ccd3a554dd1cf7922115c430
firebase_performance: 2ff356e47b63483a0da655b73c70eb780838c4e3
FirebaseABTesting: 119f0a2b2e68b1ae05d248c5adb2455f148f20c1
FirebaseAnalytics: d0a97a0db6425e5a5d966340b87f92ca7b13a557
FirebaseCore: 0e38ad5d62d980a47a64b8e9301ffa311457be04
FirebaseCoreExtension: 032fd6f8509e591fda8cb76f6651f20d926b121f
FirebaseCoreInternal: 69bf1306a05b8ac43004f6cc1f804bb7b05b229e
FirebaseCrashlytics: 3d6248c50726ee7832aef0e53cb84c9e64d9fa7e
FirebaseInstallations: 631b38da2e11a83daa4bfb482f79d286a5dfa7ad
FirebasePerformance: 9a73b170cefb06e5da537682e30f86c9865880c5
FirebaseRemoteConfig: c5dfe22828a7ae7673d16224ea92743687e993df
FirebaseRemoteConfigInterop: 3443b8cb8fffd76bb3e03b2a84bfd3db952fcda4
FirebaseSessions: 2e8f808347e665dff3e5843f275715f07045297d
FirebaseSharedSwift: 79f27fff0addd15c3de19b87fba426f3cc2c964f
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
GoogleAdsOnDeviceConversion: d68c69dd9581a0f5da02617b6f377e5be483970f
GoogleAppMeasurement: 3bf40aff49a601af5da1c3345702fcb4991d35ee
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
in_app_review: 436034b18594851a7328d7f1c2ed5ec235b79cfc
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
native_device_orientation: 42487b19c552fb5a6a2b51e61b93366f628113df
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa
wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e
webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4
PODFILE CHECKSUM: 57c8aed26fba39d3ec9424816221f294a07c58eb PODFILE CHECKSUM: 57c8aed26fba39d3ec9424816221f294a07c58eb

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1 +1 @@
Frosty Frosty for Twitch

View file

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

View file

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

View file

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

View file

@ -1,5 +1,4 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
/// Common type aliases to reduce repetition /// Common type aliases to reduce repetition
typedef JsonMap = Map<String, dynamic>; typedef JsonMap = Map<String, dynamic>;
@ -44,13 +43,6 @@ class UnauthorizedException extends ApiException {
: super(message, 401, DioExceptionType.badResponse); : super(message, 401, DioExceptionType.badResponse);
} }
/// Twitch GQL rejected the request for a missing/invalid Client-Integrity token.
class IntegrityCheckException extends ApiException {
const IntegrityCheckException([
String message = 'Twitch integrity check failed',
]) : super(message);
}
/// Base API client to eliminate code duplication across API services /// Base API client to eliminate code duplication across API services
abstract class BaseApiClient { abstract class BaseApiClient {
final Dio _dio; final Dio _dio;
@ -165,40 +157,29 @@ abstract class BaseApiClient {
/// Converts DioException to appropriate ApiException subtype /// Converts DioException to appropriate ApiException subtype
ApiException _handleError(DioException error) { ApiException _handleError(DioException error) {
final exception = switch (error.type) { switch (error.type) {
DioExceptionType.connectionTimeout || case DioExceptionType.connectionTimeout:
DioExceptionType.sendTimeout || case DioExceptionType.sendTimeout:
DioExceptionType.receiveTimeout || case DioExceptionType.receiveTimeout:
DioExceptionType.transformTimeout => return TimeoutException(_getTimeoutMessage(error.type));
TimeoutException(_getTimeoutMessage(error.type)),
DioExceptionType.connectionError => NetworkException(
'No internet connection. Please check your network.',
),
DioExceptionType.badResponse => _handleHttpError(error),
DioExceptionType.cancel => ApiException('Request was cancelled'),
DioExceptionType.badCertificate =>
ApiException('Security certificate error'),
DioExceptionType.unknown =>
ApiException(error.message ?? 'An unexpected error occurred'),
};
// Record server errors and unexpected failures to Crashlytics. case DioExceptionType.connectionError:
// Skip timeouts, network errors (normal offline conditions), 401s, and cancellations. return NetworkException(
if (exception is ServerException || 'No internet connection. Please check your network.',
error.type == DioExceptionType.unknown ||
error.type == DioExceptionType.badCertificate) {
try {
FirebaseCrashlytics.instance.recordError(
exception,
error.stackTrace,
reason: '${error.requestOptions.method} ${error.requestOptions.uri}',
); );
} catch (_) {
// Firebase may not be initialized (e.g., in tests).
}
}
return exception; case DioExceptionType.badResponse:
return _handleHttpError(error);
case DioExceptionType.cancel:
return ApiException('Request was cancelled');
case DioExceptionType.badCertificate:
return ApiException('Security certificate error');
case DioExceptionType.unknown:
return ApiException(error.message ?? 'An unexpected error occurred');
}
} }
/// Gets specific timeout message based on timeout type /// Gets specific timeout message based on timeout type
@ -210,8 +191,6 @@ abstract class BaseApiClient {
return 'Upload timeout. Please try again.'; return 'Upload timeout. Please try again.';
case DioExceptionType.receiveTimeout: case DioExceptionType.receiveTimeout:
return 'Server response timeout. Please try again.'; return 'Server response timeout. Please try again.';
case DioExceptionType.transformTimeout:
return 'Response processing timeout. Please try again.';
default: default:
return 'Request timeout. Please try again.'; return 'Request timeout. Please try again.';
} }

View file

@ -1,6 +1,5 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:frosty/apis/firebase_performance_interceptor.dart';
/// Centralized Dio client configuration with interceptors and error handling /// Centralized Dio client configuration with interceptors and error handling
class DioClient { class DioClient {
@ -56,9 +55,6 @@ class DioClient {
); );
} }
// Trace HTTP requests as Firebase Performance metrics
dio.interceptors.add(FirebasePerformanceInterceptor());
// Add any additional interceptors provided // Add any additional interceptors provided
if (additionalInterceptors != null) { if (additionalInterceptors != null) {
for (final interceptor in additionalInterceptors) { for (final interceptor in additionalInterceptors) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:frosty/main.dart'; import 'package:frosty/main.dart';
import 'package:frosty/screens/onboarding/login_webview.dart'; import 'package:frosty/screens/onboarding/login_webview.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/utils/twitch_oauth_scopes.dart';
import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:frosty/widgets/frosty_dialog.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -15,67 +14,16 @@ class UnauthorizedInterceptor extends Interceptor {
@override @override
void onError(DioException err, ErrorInterceptorHandler handler) { void onError(DioException err, ErrorInterceptorHandler handler) {
// Check if this is a 401 Unauthorized error on a Twitch Helix/OAuth request // Check if this is a 401 Unauthorized error
if (err.response?.statusCode == 401 && if (err.response?.statusCode == 401) {
_isTwitchHelixOrOAuth(err.requestOptions.uri)) {
// For token validation requests, let the error propagate so validateToken can handle it // For token validation requests, let the error propagate so validateToken can handle it
if (err.requestOptions.uri.path.endsWith('/validate')) { if (err.requestOptions.uri.path.endsWith('/validate')) {
handler.next(err); handler.next(err);
return; return;
} }
final path = err.requestOptions.uri.path; // For other requests, show login dialog and don't propagate
_showLoginDialog();
// Background moderated-channels fetches must never nag — especially for
// core-only tokens after an app upgrade.
if (isTwitchModeratedChannelsPath(path)) {
handler.reject(err);
return;
}
// Moderator actions without scopes → opt-in Enable flow, not a generic
// "missing permissions" re-login for core scopes.
if (isTwitchModeratorActionPath(path) &&
!_authStore.hasModeratorScopes) {
_showAuthDialog(
title: 'Enable moderator tools',
message: enableModeratorToolsDialogMessage(),
primaryLabel: 'Enable',
onPrimary: () => openEnableModeratorTools(_authStore),
);
handler.reject(err);
return;
}
// Core-only messaging: if core scopes are present, prefer "Session expired"
// over "Missing permissions" (401 ≠ always missing scope).
final isLoggedIn = _authStore.isLoggedIn;
final missingCore = missingTwitchScopes(
granted: _authStore.grantedScopes,
);
_showAuthDialog(
title: isLoggedIn && missingCore.isNotEmpty
? 'Missing permissions'
: 'Session expired',
message: unauthorizedDialogMessage(
isLoggedIn: isLoggedIn,
grantedScopes: _authStore.grantedScopes,
),
primaryLabel: 'Log in',
onPrimary: () {
final context = navigatorKey.currentContext;
if (context == null) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => Provider<AuthStore>.value(
value: _authStore,
child: const LoginWebView(),
),
),
);
},
);
handler.reject(err);
return; return;
} }
@ -83,20 +31,25 @@ class UnauthorizedInterceptor extends Interceptor {
handler.next(err); handler.next(err);
} }
void _showAuthDialog({ void _showLoginDialog() {
required String title, // Use the global navigator key to show dialog without needing BuildContext
required String message,
required String primaryLabel,
required VoidCallback onPrimary,
}) {
final context = navigatorKey.currentContext; final context = navigatorKey.currentContext;
if (context == null) return; if (context == null) return;
// Use a flag to prevent multiple dialogs
if (_isDialogShowing) return; if (_isDialogShowing) return;
_isDialogShowing = true; _isDialogShowing = true;
// Determine if user is logged in but missing scopes vs completely logged out
final isLoggedIn = _authStore.isLoggedIn;
final title = isLoggedIn ? 'Missing permissions' : 'Session expired';
final message = isLoggedIn
? 'Your session is missing permissions. Please log in again to continue.'
: 'Your session has expired. Please log in again to continue.';
showDialog( showDialog(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false, // User must choose an action
builder: (BuildContext dialogContext) { builder: (BuildContext dialogContext) {
return FrostyDialog( return FrostyDialog(
title: title, title: title,
@ -105,39 +58,34 @@ class UnauthorizedInterceptor extends Interceptor {
TextButton( TextButton(
onPressed: () { onPressed: () {
_isDialogShowing = false; _isDialogShowing = false;
Navigator.of(dialogContext).pop(); Navigator.of(dialogContext).pop(); // Close dialog
}, },
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
FilledButton( FilledButton(
onPressed: () { onPressed: () {
_isDialogShowing = false; _isDialogShowing = false;
Navigator.of(dialogContext).pop(); Navigator.of(dialogContext).pop(); // Close dialog
onPrimary(); // Navigate to login WebView
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => Provider<AuthStore>.value(
value: _authStore,
child: const LoginWebView(),
),
),
);
}, },
child: Text(primaryLabel), child: Text('Log in'),
), ),
], ],
); );
}, },
).then((_) { ).then((_) {
// Reset flag when dialog is dismissed
_isDialogShowing = false; _isDialogShowing = false;
}); });
} }
static bool _isDialogShowing = false; static bool _isDialogShowing = false;
bool _isTwitchHelixOrOAuth(Uri uri) {
final url = uri.toString();
return url.startsWith('https://api.twitch.tv/helix') ||
url.startsWith('https://id.twitch.tv/oauth2');
}
} }
/// Helix path for listing channels the user moderates.
bool isTwitchModeratedChannelsPath(String path) =>
path.contains('/moderation/channels');
/// Helix paths for delete / ban / timeout / unban.
bool isTwitchModeratorActionPath(String path) =>
path.contains('/moderation/chat') || path.contains('/moderation/bans');

View file

@ -3,18 +3,6 @@ import 'package:flutter/material.dart';
/// Twitch API client ID. /// Twitch API client ID.
const clientId = String.fromEnvironment('CLIENT_ID'); const clientId = String.fromEnvironment('CLIENT_ID');
/// Twitch GQL client ID (web player, used for playback access tokens).
const twitchGqlClientId = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
/// SharedPreferences key for the per-channel last-selected stream quality.
/// A saved variant that doesn't exist on the target channel (e.g. 1080p on a
/// source-only channel) falls back to Auto via the quality-list lookup.
String lastStreamQualityKey(String userLogin) =>
'last_stream_quality_$userLogin';
/// Legacy global key. Cleared on startup during SharedPreferences init.
const kLegacyLastStreamQualityKey = 'last_stream_quality';
/// Twitch API client secret. /// Twitch API client secret.
const secret = String.fromEnvironment('SECRET'); const secret = String.fromEnvironment('SECRET');
@ -54,9 +42,6 @@ final regexLink = RegExp(
caseSensitive: false, caseSensitive: false,
); );
/// Standard corner radius for card thumbnails and box art.
const kCardBorderRadius = BorderRadius.all(Radius.circular(8));
/// The default badge width and height. /// The default badge width and height.
const defaultBadgeSize = 18.0; const defaultBadgeSize = 18.0;

View file

@ -1,30 +1,22 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:advanced_in_app_review/advanced_in_app_review.dart'; import 'package:advanced_in_app_review/advanced_in_app_review.dart';
import 'package:app_links/app_links.dart'; import 'package:app_links/app_links.dart';
import 'package:dio/dio.dart';
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:firebase_performance/firebase_performance.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:frosty/apis/base_api_client.dart' show ApiException;
import 'package:frosty/apis/bttv_api.dart'; import 'package:frosty/apis/bttv_api.dart';
import 'package:frosty/apis/dio_client.dart'; import 'package:frosty/apis/dio_client.dart';
import 'package:frosty/apis/ffz_api.dart'; import 'package:frosty/apis/ffz_api.dart';
import 'package:frosty/apis/seventv_api.dart'; import 'package:frosty/apis/seventv_api.dart';
import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/apis/twitch_auth_interceptor.dart'; import 'package:frosty/apis/twitch_auth_interceptor.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:frosty/apis/unauthorized_interceptor.dart'; import 'package:frosty/apis/unauthorized_interceptor.dart';
import 'package:frosty/cache_manager.dart'; import 'package:frosty/cache_manager.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/firebase_options.dart'; import 'package:frosty/firebase_options.dart';
import 'package:frosty/screens/channel/channel.dart'; import 'package:frosty/screens/channel/channel.dart';
import 'package:frosty/screens/home/home.dart'; import 'package:frosty/screens/home/home.dart';
@ -39,25 +31,6 @@ import 'package:mobx/mobx.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
/// Errors that are expected on a mobile client — transient network/I/O, plugin
/// teardown races, and image fetch failures — and therefore shouldn't be
/// reported to Crashlytics as fatal crashes (which would tank the crash-free
/// metric and bucket unrelated errors together under the global handlers).
///
/// This is a last-resort net. The layers that own these errors already record
/// them non-fatally with precise reasons (e.g. base_api_client, the IRC
/// websocket handlers); this only catches what escapes that local handling.
bool _isNonFatalError(Object error) {
return error is SocketException ||
error is HttpException ||
error is WebSocketChannelException ||
error is ApiException ||
error is MissingPluginException ||
error is NetworkImageLoadException ||
(error is DioException && error.type != DioExceptionType.badResponse);
}
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@ -66,44 +39,17 @@ void main() async {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
// Route uncaught framework errors to Crashlytics. Non-crashing UI errors // Pass all uncaught "fatal" errors from the framework to Crashlytics
// (layout overflows, image-load failures) and other expected transient FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;
// failures are recorded as non-fatal so they don't inflate the crash count.
FlutterError.onError = (details) {
// Run the cheap type checks first; only fall back to the diagnostics string
// (RenderFlex overflow has no typed signal) when those miss.
final isNonFatal =
_isNonFatalError(details.exception) ||
details.exceptionAsString().contains('overflowed');
if (isNonFatal) {
FirebaseCrashlytics.instance.recordFlutterError(details);
} else {
FirebaseCrashlytics.instance.recordFlutterFatalError(details);
}
};
// Pass uncaught asynchronous errors to Crashlytics. Expected transient errors // Pass all uncaught asynchronous errors that aren't handled by the Flutter framework to Crashlytics
// are recorded non-fatally; everything else stays fatal. The reason gives the
// grouping engine a real signal so distinct errors don't collapse into one
// catch-all issue under this handler frame.
PlatformDispatcher.instance.onError = (error, stack) { PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError( FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
error,
stack,
reason: error.runtimeType.toString(),
fatal: !_isNonFatalError(error),
);
return true; return true;
}; };
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
// One-time migration: the quality preference used to be stored under a
// single global key, which meant a saved 1080p60 from one channel could
// pin another channel to a variant it doesn't serve. Remove the legacy
// key; new writes go under per-channel keys.
await prefs.remove(kLegacyLastStreamQualityKey);
final firstRun = prefs.getBool('first_run') ?? true; final firstRun = prefs.getBool('first_run') ?? true;
// Workaround for clearing stored tokens on uninstall. // Workaround for clearing stored tokens on uninstall.
@ -131,19 +77,6 @@ void main() async {
// Initialize a settings store from the settings JSON string. // Initialize a settings store from the settings JSON string.
final settingsStore = SettingsStore.fromJson(jsonDecode(userSettings)); final settingsStore = SettingsStore.fromJson(jsonDecode(userSettings));
// Disable Firebase collection in debug builds to avoid polluting production data.
// In release builds, respect the user's preference.
if (kDebugMode) {
FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(false);
FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(false);
FirebasePerformance.instance.setPerformanceCollectionEnabled(false);
} else {
final shareEnabled = settingsStore.shareCrashLogsAndAnalytics;
FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(shareEnabled);
FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(shareEnabled);
FirebasePerformance.instance.setPerformanceCollectionEnabled(shareEnabled);
}
// Create a MobX reaction that will save the settings on disk every time they are changed. // Create a MobX reaction that will save the settings on disk every time they are changed.
autorun((_) => prefs.setString('settings', jsonEncode(settingsStore))); autorun((_) => prefs.setString('settings', jsonEncode(settingsStore)));
@ -156,7 +89,6 @@ void main() async {
final bttvApiService = BTTVApi(dioClient); final bttvApiService = BTTVApi(dioClient);
final ffzApiService = FFZApi(dioClient); final ffzApiService = FFZApi(dioClient);
final sevenTVApiService = SevenTVApi(dioClient); final sevenTVApiService = SevenTVApi(dioClient);
final twitchGqlApiService = TwitchGqlApi(dioClient);
// Create global assets store (shared cache for global emotes/badges) // Create global assets store (shared cache for global emotes/badges)
final globalAssetsStore = GlobalAssetsStore( final globalAssetsStore = GlobalAssetsStore(
@ -167,10 +99,7 @@ void main() async {
); );
// Create and initialize the authentication store // Create and initialize the authentication store
final authStore = AuthStore( final authStore = AuthStore(twitchApi: twitchApiService);
twitchApi: twitchApiService,
twitchGqlApi: twitchGqlApiService,
);
// Add the auth interceptor to the Dio client after AuthStore creation // Add the auth interceptor to the Dio client after AuthStore creation
dioClient.interceptors.add(TwitchAuthInterceptor(authStore)); dioClient.interceptors.add(TwitchAuthInterceptor(authStore));
@ -179,10 +108,6 @@ void main() async {
dioClient.interceptors.add(UnauthorizedInterceptor(authStore)); dioClient.interceptors.add(UnauthorizedInterceptor(authStore));
await authStore.init(); await authStore.init();
FirebaseCrashlytics.instance.setCustomKey('is_logged_in', authStore.isLoggedIn);
if (authStore.isLoggedIn && authStore.user.details != null) {
FirebaseCrashlytics.instance.setUserIdentifier(authStore.user.details!.id);
}
runApp( runApp(
MultiProvider( MultiProvider(
@ -193,7 +118,6 @@ void main() async {
Provider<BTTVApi>.value(value: bttvApiService), Provider<BTTVApi>.value(value: bttvApiService),
Provider<FFZApi>.value(value: ffzApiService), Provider<FFZApi>.value(value: ffzApiService),
Provider<SevenTVApi>.value(value: sevenTVApiService), Provider<SevenTVApi>.value(value: sevenTVApiService),
Provider<TwitchGqlApi>.value(value: twitchGqlApiService),
Provider<GlobalAssetsStore>.value(value: globalAssetsStore), Provider<GlobalAssetsStore>.value(value: globalAssetsStore),
], ],
child: MyApp(firstRun: firstRun), child: MyApp(firstRun: firstRun),
@ -240,22 +164,20 @@ class _MyAppState extends State<MyApp> {
colorSchemeSeed: Color(settingsStore.accentColor), colorSchemeSeed: Color(settingsStore.accentColor),
); );
return MaterialApp( return Provider<FrostyThemes>(
title: 'Frosty', create: (_) => themes,
theme: themes.light, child: MaterialApp(
darkTheme: themes.dark, title: 'Frosty',
themeMode: settingsStore.themeType == ThemeType.system theme: themes.light,
? ThemeMode.system darkTheme: themes.dark,
: settingsStore.themeType == ThemeType.light themeMode: settingsStore.themeType == ThemeType.system
? ThemeMode.light ? ThemeMode.system
: ThemeMode.dark, : settingsStore.themeType == ThemeType.light
navigatorObservers: [ ? ThemeMode.light
FirebaseAnalyticsObserver( : ThemeMode.dark,
analytics: FirebaseAnalytics.instance, home: widget.firstRun ? const OnboardingIntro() : const Home(),
), navigatorKey: navigatorKey,
], ),
home: widget.firstRun ? const OnboardingIntro() : const Home(),
navigatorKey: navigatorKey,
); );
}, },
); );
@ -308,7 +230,6 @@ class _MyAppState extends State<MyApp> {
final user = await twitchApi.getUser(userLogin: channelName); final user = await twitchApi.getUser(userLogin: channelName);
final route = MaterialPageRoute( final route = MaterialPageRoute(
settings: const RouteSettings(name: VideoChat.routeName),
builder: (context) => VideoChat( builder: (context) => VideoChat(
userId: user.id, userId: user.id,
userName: user.displayName, userName: user.displayName,

View file

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

View file

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

View file

@ -15,14 +15,6 @@ class StreamTwitch {
final String startedAt; final String startedAt;
final String thumbnailUrl; final String thumbnailUrl;
/// ISO 639-1 broadcast language from Helix (`de`, `en`, …). Empty when unknown.
@JsonKey(defaultValue: '')
final String language;
/// Freeform tags from Helix (e.g. `Deutsch`, `English`).
@JsonKey(defaultValue: <String>[])
final List<String> tags;
const StreamTwitch( const StreamTwitch(
this.userId, this.userId,
this.userLogin, this.userLogin,
@ -32,10 +24,8 @@ class StreamTwitch {
this.title, this.title,
this.viewerCount, this.viewerCount,
this.startedAt, this.startedAt,
this.thumbnailUrl, [ this.thumbnailUrl,
this.language = '', );
this.tags = const [],
]);
factory StreamTwitch.fromJson(Map<String, dynamic> json) => factory StreamTwitch.fromJson(Map<String, dynamic> json) =>
_$StreamTwitchFromJson(json); _$StreamTwitchFromJson(json);
@ -48,20 +38,6 @@ class StreamsTwitch {
const StreamsTwitch(this.data, this.pagination); const StreamsTwitch(this.data, this.pagination);
factory StreamsTwitch.fromJson(Map<String, dynamic> json) { factory StreamsTwitch.fromJson(Map<String, dynamic> json) =>
final rawPagination = json['pagination']; _$StreamsTwitchFromJson(json);
final pagination = <String, String>{};
if (rawPagination is Map) {
final cursor = rawPagination['cursor'];
if (cursor is String && cursor.isNotEmpty) {
pagination['cursor'] = cursor;
}
}
return StreamsTwitch(
(json['data'] as List<dynamic>)
.map((e) => StreamTwitch.fromJson(e as Map<String, dynamic>))
.toList(),
pagination,
);
}
} }

View file

@ -16,8 +16,6 @@ StreamTwitch _$StreamTwitchFromJson(Map<String, dynamic> json) => StreamTwitch(
(json['viewer_count'] as num).toInt(), (json['viewer_count'] as num).toInt(),
json['started_at'] as String, json['started_at'] as String,
json['thumbnail_url'] as String, json['thumbnail_url'] as String,
json['language'] as String? ?? '',
(json['tags'] as List<dynamic>?)?.map((e) => e as String).toList() ?? [],
); );
StreamsTwitch _$StreamsTwitchFromJson(Map<String, dynamic> json) => StreamsTwitch _$StreamsTwitchFromJson(Map<String, dynamic> json) =>

View file

@ -1,50 +0,0 @@
/// ISO 639-1 codes offered in Settings for stream language filtering.
///
/// Stored lowercase (Helix). GQL `Language` enums are the uppercase form.
class StreamLanguages {
StreamLanguages._();
static const all = <({String code, String label})>[
(code: 'de', label: 'Deutsch'),
(code: 'en', label: 'English'),
(code: 'es', label: 'Español'),
(code: 'fr', label: 'Français'),
(code: 'it', label: 'Italiano'),
(code: 'pt', label: 'Português'),
(code: 'pl', label: 'Polski'),
(code: 'ru', label: 'Русский'),
(code: 'tr', label: 'Türkçe'),
(code: 'nl', label: 'Nederlands'),
(code: 'sv', label: 'Svenska'),
(code: 'no', label: 'Norsk'),
(code: 'da', label: 'Dansk'),
(code: 'fi', label: 'Suomi'),
(code: 'cs', label: 'Čeština'),
(code: 'hu', label: 'Magyar'),
(code: 'ro', label: 'Română'),
(code: 'uk', label: 'Українська'),
(code: 'ja', label: '日本語'),
(code: 'ko', label: '한국어'),
(code: 'zh', label: '中文'),
(code: 'ar', label: 'العربية'),
(code: 'th', label: 'ไทย'),
(code: 'vi', label: 'Tiếng Việt'),
(code: 'id', label: 'Bahasa Indonesia'),
(code: 'hi', label: 'हिन्दी'),
(code: 'other', label: 'Other'),
];
static String labelFor(String code) {
for (final entry in all) {
if (entry.code == code) return entry.label;
}
return code;
}
/// Helix / settings codes → GQL `Language` enum values.
static List<String> toGqlEnums(Iterable<String> codes) => codes
.map((c) => c.trim().toLowerCase())
.where((c) => c.isNotEmpty)
.map((c) => c.toUpperCase())
.toList();
}

View file

@ -6,13 +6,9 @@ import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart';
import 'package:frosty/screens/channel/video/channel_info_header.dart';
import 'package:frosty/screens/channel/video/native_video.dart';
import 'package:frosty/screens/channel/video/native_video_store.dart';
import 'package:frosty/screens/channel/video/stream_info_bar.dart'; import 'package:frosty/screens/channel/video/stream_info_bar.dart';
import 'package:frosty/screens/channel/video/video.dart'; import 'package:frosty/screens/channel/video/video.dart';
import 'package:frosty/screens/channel/video/video_overlay.dart'; import 'package:frosty/screens/channel/video/video_overlay.dart';
import 'package:frosty/screens/channel/video/video_player_interface.dart';
import 'package:frosty/screens/channel/video/video_store.dart'; import 'package:frosty/screens/channel/video/video_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/theme.dart'; import 'package:frosty/theme.dart';
@ -20,13 +16,12 @@ import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/blurred_container.dart'; import 'package:frosty/widgets/blurred_container.dart';
import 'package:frosty/widgets/draggable_divider.dart'; import 'package:frosty/widgets/draggable_divider.dart';
import 'package:frosty/widgets/frosty_notification.dart'; import 'package:frosty/widgets/frosty_notification.dart';
import 'package:provider/provider.dart';
import 'package:simple_pip_mode/actions/pip_actions_layout.dart'; import 'package:simple_pip_mode/actions/pip_actions_layout.dart';
import 'package:simple_pip_mode/pip_widget.dart'; import 'package:simple_pip_mode/pip_widget.dart';
/// Creates a widget that shows the video stream (if live) and chat of the given user. /// Creates a widget that shows the video stream (if live) and chat of the given user.
class VideoChat extends StatefulWidget { class VideoChat extends StatefulWidget {
static const routeName = 'VideoChat';
final String userId; final String userId;
final String userName; final String userName;
final String userLogin; final String userLogin;
@ -47,10 +42,11 @@ class _VideoChatState extends State<VideoChat>
final _videoKey = GlobalKey(); final _videoKey = GlobalKey();
final _chatKey = GlobalKey(); final _chatKey = GlobalKey();
// PiP drag state — ValueNotifier avoids full widget rebuilds during drag. // PiP drag state - essential only
final _pipDragDistance = ValueNotifier<double>(0); double _pipDragDistance = 0;
bool _isPipDragging = false; bool _isPipDragging = false;
bool _isInPipTriggerZone = false; bool _isInPipTriggerZone =
false; // Track when in trigger zone for haptic feedback
// Divider drag state for synchronizing animation // Divider drag state for synchronizing animation
bool _isDividerDragging = false; bool _isDividerDragging = false;
@ -63,8 +59,6 @@ class _VideoChatState extends State<VideoChat>
late AnimationController _animationController; late AnimationController _animationController;
late Animation<double> _springBackAnimation; late Animation<double> _springBackAnimation;
bool? _isImmersiveSystemUiEnabled;
late final ChatTabsStore _chatTabsStore = ChatTabsStore( late final ChatTabsStore _chatTabsStore = ChatTabsStore(
twitchApi: context.twitchApi, twitchApi: context.twitchApi,
bttvApi: context.bttvApi, bttvApi: context.bttvApi,
@ -78,24 +72,13 @@ class _VideoChatState extends State<VideoChat>
primaryDisplayName: widget.userName, primaryDisplayName: widget.userName,
); );
late final VideoPlayerInterface _videoStore = late final VideoStore _videoStore = VideoStore(
context.settingsStore.useNativePlayer userLogin: widget.userLogin,
? NativeVideoStore( userId: widget.userId,
userLogin: widget.userLogin, twitchApi: context.twitchApi,
userId: widget.userId, authStore: context.authStore,
displayName: widget.userName, settingsStore: context.settingsStore,
twitchApi: context.twitchApi, );
twitchGqlApi: context.twitchGqlApi,
authStore: context.authStore,
settingsStore: context.settingsStore,
)
: VideoStore(
userLogin: widget.userLogin,
userId: widget.userId,
twitchApi: context.twitchApi,
authStore: context.authStore,
settingsStore: context.settingsStore,
);
@override @override
void initState() { void initState() {
@ -108,15 +91,14 @@ class _VideoChatState extends State<VideoChat>
); );
// Spring-back animation with smooth easing // Spring-back animation with smooth easing
_springBackAnimation = Tween<double>(begin: 0, end: 0).animate( _springBackAnimation =
CurvedAnimation(parent: _animationController, curve: Curves.easeOut), Tween<double>(begin: 0, end: 0).animate(
); CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
)..addListener(() {
// Drive the ValueNotifier from the spring-back animation so the setState(() {
// AnimatedBuilder rebuilds without a full setState. _pipDragDistance = _springBackAnimation.value;
_animationController.addListener(() { });
_pipDragDistance.value = _springBackAnimation.value; });
});
// Register as observer for app lifecycle events // Register as observer for app lifecycle events
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
@ -132,58 +114,58 @@ class _VideoChatState extends State<VideoChat>
} }
void _handlePipDragStart(DragStartDetails details) { void _handlePipDragStart(DragStartDetails details) {
if (_videoStore.isInPipMode || _videoStore.paused || _videoStore.loading) { // Disable drag gesture when already in PiP mode or video is not playing
return; if (_videoStore.isInPipMode || _videoStore.paused) return;
}
_animationController.stop(); _animationController.stop(); // Stop any ongoing animation
_pipDragDistance.value = 0;
_isInPipTriggerZone = false;
setState(() { setState(() {
_isPipDragging = true; _isPipDragging = true;
_pipDragDistance = 0;
_isInPipTriggerZone = false;
}); });
} }
void _handlePipDragUpdate(DragUpdateDetails details) { void _handlePipDragUpdate(DragUpdateDetails details) {
if (!_isPipDragging || if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) {
_videoStore.isInPipMode ||
_videoStore.paused ||
_videoStore.loading) {
return; return;
} }
// Update the ValueNotifier directly — only the ListenableBuilder rebuilds, setState(() {
// not the entire channel scaffold. _pipDragDistance += details.delta.dy;
_pipDragDistance.value = _pipDragDistance = _pipDragDistance.clamp(0, _pipMaxDragDistance);
(_pipDragDistance.value + details.delta.dy).clamp(0, _pipMaxDragDistance);
final wasInTriggerZone = _isInPipTriggerZone; // Check if we've entered or exited the trigger zone for haptic feedback
_isInPipTriggerZone = _pipDragDistance.value >= _pipTriggerDistance; final wasInTriggerZone = _isInPipTriggerZone;
_isInPipTriggerZone = _pipDragDistance >= _pipTriggerDistance;
if (!wasInTriggerZone && _isInPipTriggerZone) { // Provide haptic feedback when entering the trigger zone
HapticFeedback.mediumImpact(); if (!wasInTriggerZone && _isInPipTriggerZone) {
} else if (wasInTriggerZone && !_isInPipTriggerZone) { HapticFeedback.mediumImpact(); // Entering trigger zone
HapticFeedback.lightImpact(); }
} // Provide subtle haptic feedback when exiting the trigger zone
else if (wasInTriggerZone && !_isInPipTriggerZone) {
HapticFeedback.lightImpact(); // Exiting trigger zone
}
});
} }
void _handlePipDragEnd(DragEndDetails details) { void _handlePipDragEnd(DragEndDetails details) {
if (!_isPipDragging || if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) {
_videoStore.isInPipMode ||
_videoStore.paused ||
_videoStore.loading) {
return; return;
} }
final velocity = details.velocity.pixelsPerSecond.dy; final velocity = details.velocity.pixelsPerSecond.dy;
final shouldTriggerPip = final shouldTriggerPip =
_pipDragDistance.value >= _pipTriggerDistance || velocity > 600; _pipDragDistance >= _pipTriggerDistance ||
velocity > 600; // Simple velocity threshold
if (shouldTriggerPip) { if (shouldTriggerPip) {
// Simple haptic feedback on success
HapticFeedback.mediumImpact(); HapticFeedback.mediumImpact();
_videoStore.requestPictureInPicture(); _videoStore.requestPictureInPicture();
_resetDragState(); _resetDragState();
} else { } else {
// Animate back to original position
_animateSpringBack(); _animateSpringBack();
} }
} }
@ -194,16 +176,16 @@ class _VideoChatState extends State<VideoChat>
} }
void _resetDragState() { void _resetDragState() {
_pipDragDistance.value = 0;
_isInPipTriggerZone = false;
setState(() { setState(() {
_isPipDragging = false; _isPipDragging = false;
_pipDragDistance = 0;
_isInPipTriggerZone = false;
}); });
} }
void _animateSpringBack() { void _animateSpringBack() {
_springBackAnimation = _springBackAnimation = Tween<double>(begin: _pipDragDistance, end: 0)
Tween<double>(begin: _pipDragDistance.value, end: 0).animate( .animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeOut), CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
); );
@ -213,45 +195,26 @@ class _VideoChatState extends State<VideoChat>
}); });
} }
void _scheduleSystemUiModeSync({required bool immersive}) {
if (_isImmersiveSystemUiEnabled == immersive) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _isImmersiveSystemUiEnabled == immersive) return;
_isImmersiveSystemUiEnabled = immersive;
if (immersive) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
} else {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
}
});
}
/// Wraps a video widget with PiP swipe-down gesture handling. /// Wraps a video widget with PiP swipe-down gesture handling.
/// ///
/// Provides visual feedback (translate + scale), haptic feedback, /// Provides visual feedback (translate + scale), haptic feedback,
/// and an instructional overlay during the drag gesture. /// and an instructional overlay during the drag gesture.
Widget _buildPipGestureWrapper({required Widget child, double? aspectRatio}) { Widget _buildPipGestureWrapper({required Widget child, double? aspectRatio}) {
final wrappedChild = aspectRatio != null return AnimatedBuilder(
? AspectRatio(aspectRatio: aspectRatio, child: child) animation: Listenable.merge([_animationController, _springBackAnimation]),
: child; builder: (context, _) {
final currentDragDistance = _isPipDragging
// Listen to the ValueNotifier (drag) and AnimationController (spring-back) ? _pipDragDistance
// so only this subtree rebuilds during the gesture — not the full scaffold. : _springBackAnimation.value;
return ListenableBuilder(
listenable: _pipDragDistance,
child: wrappedChild,
builder: (context, animatedChild) {
final currentDragDistance = _pipDragDistance.value;
final scaleFactor = final scaleFactor =
1.0 - (currentDragDistance / _pipMaxDragDistance * 0.1); 1.0 - (currentDragDistance / _pipMaxDragDistance * 0.1);
Widget content = child;
if (aspectRatio != null) {
content = AspectRatio(aspectRatio: aspectRatio, child: child);
}
return Transform.translate( return Transform.translate(
offset: Offset(0, currentDragDistance), offset: Offset(0, currentDragDistance),
child: Transform.scale( child: Transform.scale(
@ -263,18 +226,16 @@ class _VideoChatState extends State<VideoChat>
onPanUpdate: _handlePipDragUpdate, onPanUpdate: _handlePipDragUpdate,
onPanEnd: _handlePipDragEnd, onPanEnd: _handlePipDragEnd,
onPanCancel: _handlePipDragCancel, onPanCancel: _handlePipDragCancel,
child: animatedChild, child: content,
), ),
if (!_videoStore.isInPipMode) if (!_videoStore.isInPipMode)
Positioned.fill( Positioned.fill(
child: IgnorePointer( child: IgnorePointer(
ignoring: ignoring: !(_isPipDragging && _pipDragDistance > 0),
!(_isPipDragging && currentDragDistance > 0),
child: AnimatedOpacity( child: AnimatedOpacity(
opacity: opacity: (_isPipDragging && _pipDragDistance > 0)
(_isPipDragging && currentDragDistance > 0) ? 1.0
? 1.0 : 0.0,
: 0.0,
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
curve: Curves.easeOut, curve: Curves.easeOut,
child: Container( child: Container(
@ -311,19 +272,7 @@ class _VideoChatState extends State<VideoChat>
final player = GestureDetector( final player = GestureDetector(
onLongPress: _videoStore.handleToggleOverlay, onLongPress: _videoStore.handleToggleOverlay,
child: _videoStore is NativeVideoStore child: Video(key: _videoKey, videoStore: _videoStore),
? NativeVideo(
// GlobalKey so the native platform view is *reparented* (not
// rebuilt) across the portrait↔landscape layout swap — without
// it, rotating destroys and recreates the player, forcing a
// reload. Mirrors the WebView Video below.
key: _videoKey,
nativeVideoStore: _videoStore,
)
: Video(
key: _videoKey,
videoStore: _videoStore as VideoStore,
),
); );
final overlay = GestureDetector( final overlay = GestureDetector(
@ -350,9 +299,7 @@ class _VideoChatState extends State<VideoChat>
settingsStore: settingsStore, settingsStore: settingsStore,
); );
if (_videoStore.paused || if (_videoStore.paused || _videoStore.streamInfo == null) {
_videoStore.streamInfo == null ||
_videoStore.loading) {
return videoOverlay; return videoOverlay;
} }
@ -372,49 +319,12 @@ class _VideoChatState extends State<VideoChat>
), ),
); );
final video = Stack( final video = Observer(
children: [ builder: (context) {
player, if (!_videoStore.settingsStore.showOverlay) return player;
Positioned.fill(
child: Observer( return Stack(children: [player, overlay]);
builder: (_) => AnimatedOpacity( },
opacity: _videoStore.loading ? 1.0 : 0.0,
// Appear instantly to cover native player placeholders during
// recovery; fade out smoothly when playback resumes.
duration: _videoStore.loading
? Duration.zero
: const Duration(milliseconds: 300),
curve: Curves.easeOut,
// TickerMode stops the spinner's repeating animation while
// hidden — opacity 0 skips painting but would otherwise keep
// scheduling frames for the whole video session.
child: IgnorePointer(
child: TickerMode(
enabled: _videoStore.loading,
child: const ColoredBox(
color: Colors.black,
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: Colors.white38,
strokeWidth: 2,
),
),
),
),
),
),
),
),
),
Observer(
builder: (_) => _videoStore.settingsStore.showOverlay
? overlay
: const SizedBox.shrink(),
),
],
); );
final chat = Observer( final chat = Observer(
@ -459,11 +369,6 @@ class _VideoChatState extends State<VideoChat>
final videoChat = Observer( final videoChat = Observer(
builder: (context) { builder: (context) {
_scheduleSystemUiModeSync(
immersive:
context.isLandscape && !settingsStore.landscapeForceVerticalChat,
);
// Build a blurred AppBar when in chat-only mode (no video) // Build a blurred AppBar when in chat-only mode (no video)
PreferredSizeWidget? chatOnlyBlurredAppBar; PreferredSizeWidget? chatOnlyBlurredAppBar;
if (!settingsStore.showVideo) { if (!settingsStore.showVideo) {
@ -505,6 +410,10 @@ class _VideoChatState extends State<VideoChat>
builder: (context) { builder: (context) {
if (context.isLandscape && if (context.isLandscape &&
!settingsStore.landscapeForceVerticalChat) { !settingsStore.landscapeForceVerticalChat) {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.immersiveSticky,
);
final landscapeChat = AnimatedContainer( final landscapeChat = AnimatedContainer(
curve: Curves.ease, curve: Curves.ease,
duration: _isDividerDragging duration: _isDividerDragging
@ -530,7 +439,11 @@ class _VideoChatState extends State<VideoChat>
).dark, ).dark,
child: DefaultTextStyle( child: DefaultTextStyle(
style: context.defaultTextStyle.copyWith( style: context.defaultTextStyle.copyWith(
color: context.frostyColors.overlayOnSurface, color: context
.watch<FrostyThemes>()
.dark
.colorScheme
.onSurface,
), ),
child: landscapeChat, child: landscapeChat,
), ),
@ -625,114 +538,124 @@ class _VideoChatState extends State<VideoChat>
) )
: SafeArea( : SafeArea(
bottom: false, bottom: false,
left: left: settingsStore.landscapeCutout !=
settingsStore.landscapeCutout !=
LandscapeCutoutType.left && LandscapeCutoutType.left &&
settingsStore.landscapeCutout != settingsStore.landscapeCutout !=
LandscapeCutoutType.both, LandscapeCutoutType.both,
right: right: settingsStore.landscapeCutout !=
settingsStore.landscapeCutout !=
LandscapeCutoutType.right && LandscapeCutoutType.right &&
settingsStore.landscapeCutout != settingsStore.landscapeCutout !=
LandscapeCutoutType.both, LandscapeCutoutType.both,
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final availableWidth = constraints.maxWidth; final availableWidth =
final chatWidth = _chatStore.expandChat constraints.maxWidth;
? 0.5 final chatWidth = _chatStore.expandChat
: settingsStore.chatWidth; ? 0.5
: settingsStore.chatWidth;
// Create the landscape chat container with proper styling // Create the landscape chat container with proper styling
final chatContainer = AnimatedContainer( final chatContainer = AnimatedContainer(
curve: Curves.ease, curve: Curves.ease,
duration: _isDividerDragging duration: _isDividerDragging
? Duration.zero ? Duration.zero
: const Duration(milliseconds: 200), : const Duration(
width: availableWidth * chatWidth, milliseconds: 200,
color: settingsStore.fullScreen ),
? Colors.black.withValues( width: availableWidth * chatWidth,
alpha: settingsStore color: settingsStore.fullScreen
.fullScreenChatOverlayOpacity, ? Colors.black.withValues(
) alpha: settingsStore
: context.scaffoldColor, .fullScreenChatOverlayOpacity,
child: chat, )
); : context.scaffoldColor,
child: chat,
);
final draggableDivider = Observer( final draggableDivider = Observer(
builder: (_) => DraggableDivider( builder: (_) => DraggableDivider(
currentWidth: chatWidth, currentWidth: chatWidth,
maxWidth: 0.6, maxWidth: 0.6,
isResizableOnLeft: isResizableOnLeft: settingsStore
settingsStore.landscapeChatLeftSide, .landscapeChatLeftSide,
showHandle: _videoStore.overlayVisible, showHandle:
onDragStart: () { _videoStore.overlayVisible,
setState(() { onDragStart: () {
_isDividerDragging = true; setState(() {
}); _isDividerDragging = true;
}, });
onDrag: (newWidth) { },
if (!_chatStore.expandChat) { onDrag: (newWidth) {
settingsStore.chatWidth = newWidth; if (!_chatStore.expandChat) {
} settingsStore.chatWidth =
}, newWidth;
onDragEnd: () { }
setState(() { },
_isDividerDragging = false; onDragEnd: () {
}); setState(() {
}, _isDividerDragging = false;
), });
); },
),
);
return Stack( return Stack(
children: [ children: [
Row( Row(
children: children:
settingsStore settingsStore
.landscapeChatLeftSide .landscapeChatLeftSide
? [ ? [
chatContainer, chatContainer,
Expanded( Expanded(
child: child:
_buildPipGestureWrapper( _buildPipGestureWrapper(
child: video, child: video,
), ),
), ),
] ]
: [ : [
Expanded( Expanded(
child: child:
_buildPipGestureWrapper( _buildPipGestureWrapper(
child: video, child: video,
), ),
), ),
chatContainer, chatContainer,
], ],
), ),
Positioned( Positioned(
top: 0, top: 0,
bottom: 0, bottom: 0,
left: left:
settingsStore settingsStore
.landscapeChatLeftSide .landscapeChatLeftSide
? (availableWidth * chatWidth) - ? (availableWidth *
12 chatWidth) -
: null, 12
right: : null,
!settingsStore right:
.landscapeChatLeftSide !settingsStore
? (availableWidth * chatWidth) - .landscapeChatLeftSide
12 ? (availableWidth *
: null, chatWidth) -
child: draggableDivider, 12
), : null,
], child: draggableDivider,
); ),
}, ],
), );
) },
),
)
: SafeArea(child: chat), : SafeArea(child: chat),
); );
} }
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
return SafeArea( return SafeArea(
// Keep chat-only scrolling under the blurred app bar, but // Keep chat-only scrolling under the blurred app bar, but
// ensure video does not bleed into the system status bar. // ensure video does not bleed into the system status bar.
@ -745,15 +668,7 @@ class _VideoChatState extends State<VideoChat>
if (settingsStore.showVideo) ...[ if (settingsStore.showVideo) ...[
AspectRatio( AspectRatio(
aspectRatio: 16 / 9, aspectRatio: 16 / 9,
child: SizedBox(), child: Container(), // Placeholder for video space
),
ChannelInfoHeader(
videoStore: _videoStore,
authStore: context.authStore,
userId: widget.userId,
displayName: widget.userName,
isInSharedChatMode:
_chatStore.isInSharedChatMode,
), ),
], ],
Expanded(child: chat), Expanded(child: chat),
@ -780,16 +695,9 @@ class _VideoChatState extends State<VideoChat>
// If on Android, use PiPSwitcher to enable PiP functionality. // If on Android, use PiPSwitcher to enable PiP functionality.
if (Platform.isAndroid) { if (Platform.isAndroid) {
// Both players need this — the WebView store used to be skipped by a
// NativeVideoPlayerInterface type check, so it never learned it was in
// PiP on Android.
void notifyPip(bool isInPip) => _videoStore.handleAndroidPipChanged(isInPip);
return PipWidget( return PipWidget(
pipLayout: PipActionsLayout.mediaOnlyPause, pipLayout: PipActionsLayout.mediaOnlyPause,
onPipAction: (_) => _videoStore.handlePausePlay(), onPipAction: (_) => _videoStore.handlePausePlay(),
onPipEntered: () => notifyPip(true),
onPipExited: () => notifyPip(false),
pipChild: player, pipChild: player,
child: videoChat, child: videoChat,
); );
@ -808,7 +716,6 @@ class _VideoChatState extends State<VideoChat>
_videoStore.dispose(); _videoStore.dispose();
_animationController.dispose(); _animationController.dispose();
_pipDragDistance.dispose();
SystemChrome.setEnabledSystemUIMode( SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual, SystemUiMode.manual,

View file

@ -1,21 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart'; import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart';
import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart'; import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_bottom_bar.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_bottom_bar.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/utils/context_extensions.dart'; import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/frosty_page_view.dart'; import 'package:frosty/widgets/frosty_page_view.dart';
import 'package:frosty/widgets/frosty_scrollbar.dart'; import 'package:frosty/widgets/frosty_scrollbar.dart';
/// ~2x default to keep richer message widgets (emotes, badges, replies) built
/// ahead of fast scroll-back without holding the entire history.
const _chatCacheExtent = ScrollCacheExtent.pixels(500.0);
class Chat extends StatelessWidget { class Chat extends StatelessWidget {
final ChatStore chatStore; final ChatStore chatStore;
final EdgeInsetsGeometry? listPadding; final EdgeInsetsGeometry? listPadding;
@ -24,20 +17,13 @@ class Chat extends StatelessWidget {
/// Passes this to ChatBottomBar for the ChatDetails menu. /// Passes this to ChatBottomBar for the ChatDetails menu.
final VoidCallback onAddChat; final VoidCallback onAddChat;
/// When set, the widget operates in merged mode: messages come from all tabs
/// and the active tab determines the send target.
final ChatTabsStore? chatTabsStore;
const Chat({ const Chat({
super.key, super.key,
required this.chatStore, required this.chatStore,
this.listPadding, this.listPadding,
required this.onAddChat, required this.onAddChat,
this.chatTabsStore,
}); });
bool get _isMerged => chatTabsStore != null;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Observer( return Observer(
@ -82,63 +68,40 @@ class Chat extends StatelessWidget {
? 0.0 ? 0.0
: MediaQuery.of(context).padding.bottom; : MediaQuery.of(context).padding.bottom;
final scrollController = _isMerged return FrostyScrollbar(
? chatTabsStore!.mergedScrollController controller: chatStore.scrollController,
: chatStore.scrollController; padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top,
// Freeze the message list while a finger rests on a bottom:
// message so a long-press target can't scroll out chatStore.bottomBarHeight + bottomPadding,
// from under it (which would dispose its recognizer ),
// mid-press). The store resumes flowing as soon as child: Observer(
// the pointer moves like a scroll, so scrolling is builder: (context) {
// never interrupted — only a stationary hold pauses. return ListView.builder(
void onDown(PointerDownEvent e) => _isMerged reverse: true,
? chatTabsStore!.onMessageListPointerDown( padding: (listPadding ?? EdgeInsets.zero)
e.pointer, .add(
e.position, EdgeInsets.only(
) bottom:
: chatStore.onMessageListPointerDown( chatStore.bottomBarHeight +
e.pointer, bottomPadding,
e.position, ),
),
addAutomaticKeepAlives: false,
controller: chatStore.scrollController,
itemCount: chatStore.renderMessages.length,
itemBuilder: (context, index) =>
ChatMessage(
ircMessage:
chatStore.renderMessages[chatStore
.renderMessages
.length -
1 -
index],
chatStore: chatStore,
),
); );
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,
);
},
),
), ),
); );
}, },
@ -146,6 +109,19 @@ class Chat extends StatelessWidget {
), ),
), ),
), ),
// Prevents accidental chat scrolling when swiping down from the top edge
// to access system UI (Notification Center/Control Center) in landscape mode.
if (context.isLandscape)
Positioned(
top: 0,
left: 0,
right: 0,
height: 24,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onVerticalDragStart: (_) {},
),
),
Positioned( Positioned(
left: 0, left: 0,
right: 0, right: 0,
@ -153,227 +129,116 @@ class Chat extends StatelessWidget {
child: ChatBottomBar( child: ChatBottomBar(
chatStore: chatStore, chatStore: chatStore,
onAddChat: onAddChat, onAddChat: onAddChat,
channelDisplayName: _isMerged
? getReadableName(
chatTabsStore!.activeTab.displayName,
chatTabsStore!.activeTab.channelLogin,
)
: null,
), ),
), ),
_buildResumeScrollButton(), Builder(
builder: (context) {
final isHorizontalLandscape =
context.isLandscape &&
!chatStore.settings.landscapeForceVerticalChat;
return AnimatedPadding(
duration: const Duration(milliseconds: 200),
padding: EdgeInsets.only(
left: 4,
top: 4,
right: 4,
bottom:
chatStore.bottomBarHeight +
(chatStore.assetsStore.showEmoteMenu ||
isHorizontalLandscape
? 0
: MediaQuery.of(context).padding.bottom),
),
child: Observer(
builder: (_) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: chatStore.autoScroll
? null
: ElevatedButton.icon(
onPressed: chatStore.resumeScroll,
icon: const Icon(
Icons.arrow_downward_rounded,
),
label: Text(
chatStore.messageBuffer.isNotEmpty
? '${chatStore.messageBuffer.length} new ${chatStore.messageBuffer.length == 1 ? 'message' : 'messages'}'
: 'Resume scroll',
style: const TextStyle(
fontFeatures: [
FontFeature.tabularFigures(),
],
),
),
),
),
),
);
},
),
], ],
), ),
), ),
_buildEmoteMenu(context), AnimatedContainer(
curve: Curves.ease,
duration: const Duration(milliseconds: 200),
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(
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',
],
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,
),
),
], ],
); );
}, },
); );
} }
Widget _buildNormalList(
ScrollController scrollController,
double bottomPadding,
) {
return ListView.builder(
reverse: true,
padding: (listPadding ?? EdgeInsets.zero).add(
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,
);
},
);
}
Widget _buildMergedList(
ScrollController scrollController,
double bottomPadding,
) {
final mergedMessages = chatTabsStore!.mergedMessages;
final channelIdToUserTwitch = chatTabsStore!.mergedChannelIdToUserTwitch;
final currentChannelId = chatTabsStore!.activeTab.channelId;
return ListView.builder(
reverse: true,
padding: (listPadding ?? EdgeInsets.zero).add(
EdgeInsets.only(bottom: chatStore.bottomBarHeight + bottomPadding),
),
addAutomaticKeepAlives: false,
scrollCacheExtent: _chatCacheExtent,
controller: scrollController,
itemCount: mergedMessages.length,
itemBuilder: (context, index) {
final merged = mergedMessages[mergedMessages.length - 1 - index];
return ChatMessage(
key: ObjectKey(merged.ircMessage),
ircMessage: merged.ircMessage,
chatStore: merged.chatStore,
inputChatStore: chatStore,
onActivateSourceTab: () {
final tabs = chatTabsStore!.tabs;
final idx = tabs.indexWhere(
(t) => t.channelId == merged.chatStore.channelId,
);
if (idx != -1 && idx != chatTabsStore!.activeTabIndex) {
chatTabsStore!.setActiveTab(idx, silent: true);
}
},
overrideChannelIdToUserTwitch: channelIdToUserTwitch,
overrideCurrentChannelId: currentChannelId,
);
},
);
}
Widget _buildResumeScrollButton() {
return Builder(
builder: (context) {
final isHorizontalLandscape =
context.isLandscape &&
!chatStore.settings.landscapeForceVerticalChat;
return AnimatedPadding(
duration: const Duration(milliseconds: 200),
padding: EdgeInsets.only(
left: 4,
top: 4,
right: 4,
bottom:
chatStore.bottomBarHeight +
(chatStore.assetsStore.showEmoteMenu || isHorizontalLandscape
? 0
: MediaQuery.of(context).padding.bottom),
),
child: Observer(
builder: (_) {
final isAutoScrolling = _isMerged
? chatTabsStore!.mergedAutoScroll
: chatStore.autoScroll;
final bufferCount = _isMerged
? chatTabsStore!.mergedBufferCount
: chatStore.messageBuffer.length;
final onResume = _isMerged
? chatTabsStore!.resumeMergedScroll
: chatStore.resumeScroll;
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: isAutoScrolling
? null
: ElevatedButton.icon(
onPressed: onResume,
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()],
),
),
),
);
},
),
);
},
);
}
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,
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,
),
),
],
),
),
],
),
),
)
: null,
),
);
}
} }

View file

@ -416,7 +416,6 @@ class _ChatDetailsState extends State<ChatDetails> {
onTap: () => Navigator.push( onTap: () => Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
settings: const RouteSettings(name: Settings.routeName),
builder: (context) => builder: (context) =>
Settings(settingsStore: widget.chatStore.settings), Settings(settingsStore: widget.chatStore.settings),
), ),

View file

@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/models/irc.dart'; import 'package:frosty/models/irc.dart';
import 'package:frosty/utils/context_extensions.dart';
class ChatModes extends StatelessWidget { class ChatModes extends StatelessWidget {
final ROOMSTATE roomState; final ROOMSTATE roomState;
@ -28,7 +27,7 @@ class ChatModes extends StatelessWidget {
label: 'Emote only', label: 'Emote only',
activeLabel: 'Emote only', activeLabel: 'Emote only',
isActive: true, isActive: true,
activeColor: context.frostyColors.emoteOnly, activeColor: const Color(0xFFFFB74D), // Slightly lighter orange
), ),
), ),
); );
@ -45,7 +44,7 @@ class ChatModes extends StatelessWidget {
label: 'Follower only', label: 'Follower only',
activeLabel: 'Follower only', activeLabel: 'Follower only',
isActive: true, isActive: true,
activeColor: context.frostyColors.followersOnly, activeColor: const Color(0xFFF44336), // Bright red
duration: _getFollowersDuration(), duration: _getFollowersDuration(),
), ),
), ),
@ -63,7 +62,7 @@ class ChatModes extends StatelessWidget {
label: 'Slow mode', label: 'Slow mode',
activeLabel: 'Slow mode', activeLabel: 'Slow mode',
isActive: true, isActive: true,
activeColor: context.frostyColors.slowMode, activeColor: const Color(0xFF2196F3), // Bright blue
duration: _getSlowModeDuration(), duration: _getSlowModeDuration(),
), ),
), ),
@ -81,7 +80,7 @@ class ChatModes extends StatelessWidget {
label: 'Sub only', label: 'Sub only',
activeLabel: 'Sub only', activeLabel: 'Sub only',
isActive: true, isActive: true,
activeColor: context.frostyColors.subOnly, activeColor: const Color(0xFF4CAF50), // Bright green
), ),
), ),
); );
@ -98,7 +97,7 @@ class ChatModes extends StatelessWidget {
label: 'Unique mode', label: 'Unique mode',
activeLabel: 'Unique mode', activeLabel: 'Unique mode',
isActive: true, isActive: true,
activeColor: context.frostyColors.uniqueMode, activeColor: const Color(0xFFAB47BC), // Slightly lighter purple
), ),
), ),
); );
@ -171,7 +170,7 @@ class ChatModes extends StatelessWidget {
style: TextStyle( style: TextStyle(
color: Theme.of( color: Theme.of(
context, context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.7), ).colorScheme.onSurface.withValues(alpha: 0.6),
), ),
), ),
], ],

View file

@ -8,6 +8,7 @@ import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/utils/context_extensions.dart'; import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/alert_message.dart'; import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/frosty_cached_network_image.dart'; import 'package:frosty/widgets/frosty_cached_network_image.dart';
import 'package:frosty/widgets/frosty_dialog.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class RecentEmotesPanel extends StatelessWidget { class RecentEmotesPanel extends StatelessWidget {
@ -15,74 +16,122 @@ class RecentEmotesPanel extends StatelessWidget {
const RecentEmotesPanel({super.key, required this.chatStore}); const RecentEmotesPanel({super.key, required this.chatStore});
Future<void> _showClearDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => FrostyDialog(
title: 'Clear recent emotes',
message: 'Are you sure you want to clear your recent emotes?',
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
chatStore.assetsStore.recentEmotes.clear();
chatStore.updateNotification('Recent emotes cleared');
Navigator.pop(context);
},
child: const Text('Clear'),
),
],
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Observer( return Observer(
builder: (context) { builder: (context) {
if (chatStore.assetsStore.recentEmotes.isEmpty) {
return const Center(
child: AlertMessage(message: 'No recent emotes'),
);
}
return MediaQuery.removePadding( return MediaQuery.removePadding(
context: context, context: context,
removeTop: true, removeTop: true,
child: GridView.builder( child: CustomScrollView(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( slivers: [
crossAxisCount: context.isPortrait if (chatStore.assetsStore.recentEmotes.isEmpty)
? 8 const SliverFillRemaining(
: context.read<SettingsStore>().showVideo hasScrollBody: false,
? 6 child: AlertMessage(message: 'No recent emotes'),
: 16, )
), else
itemBuilder: (context, index) { SliverGrid.builder(
final emote = chatStore.assetsStore.recentEmotes[index]; gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
final validEmotes = [ crossAxisCount: context.isPortrait
...chatStore.assetsStore.emoteToObject.values, ? 8
...chatStore.assetsStore.userEmoteToObject.values, : context.read<SettingsStore>().showVideo
]; ? 6
final matchingEmotes = validEmotes.where( : 16,
(existingEmote) => ),
existingEmote.name == emote.name && itemBuilder: (context, index) {
existingEmote.type == emote.type, final emote = chatStore.assetsStore.recentEmotes[index];
); final validEmotes = [
...chatStore.assetsStore.emoteToObject.values,
...chatStore.assetsStore.userEmoteToObject.values,
];
final matchingEmotes = validEmotes.where(
(existingEmote) =>
existingEmote.name == emote.name &&
existingEmote.type == emote.type,
);
return InkWell( return InkWell(
onTap: matchingEmotes.isNotEmpty onTap: matchingEmotes.isNotEmpty
? () => chatStore.addEmote(emote) ? () => chatStore.addEmote(emote)
: null, : null,
onLongPress: () { onLongPress: () {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
IRCMessage.showEmoteDetailsBottomSheet( IRCMessage.showEmoteDetailsBottomSheet(
context, context,
emote: emote, emote: emote,
launchExternal: chatStore.settings.launchUrlExternal, launchExternal: chatStore.settings.launchUrlExternal,
); );
}, },
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Center( child: Center(
child: FrostyCachedNetworkImage( child: FrostyCachedNetworkImage(
imageUrl: matchingEmotes.isNotEmpty imageUrl: matchingEmotes.isNotEmpty
? matchingEmotes.first.url ? matchingEmotes.first.url
: emote.url, : emote.url,
color: matchingEmotes.isNotEmpty color: matchingEmotes.isNotEmpty
? null ? null
: const Color.fromRGBO(255, 255, 255, 0.5), : const Color.fromRGBO(255, 255, 255, 0.5),
colorBlendMode: matchingEmotes.isNotEmpty colorBlendMode: matchingEmotes.isNotEmpty
? null ? null
: BlendMode.modulate, : BlendMode.modulate,
height: height:
emote.height?.toDouble() ?? defaultEmoteSize, emote.height?.toDouble() ?? defaultEmoteSize,
width: emote.width?.toDouble(), width: emote.width?.toDouble(),
),
),
),
);
},
itemCount: chatStore.assetsStore.recentEmotes.length,
),
SliverFillRemaining(
hasScrollBody: false,
child: Align(
alignment: Alignment.bottomCenter,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: SizedBox(
width: double.infinity,
child: TextButton.icon(
onPressed: () => _showClearDialog(context),
icon: const Icon(Icons.clear_all_rounded),
label: const Text('Clear recent emotes'),
),
),
), ),
), ),
), ),
); ),
}, ],
itemCount: chatStore.assetsStore.recentEmotes.length,
), ),
); );
}, },

View file

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

View file

@ -130,7 +130,7 @@ abstract class ChatAssetsStoreBase with Store {
@action @action
Future<void> init() async { Future<void> init() async {
// Load SharedPreferences for recent emotes. // Retrieve the instance that will allow us to retrieve local search history.
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_recentEmotes = _recentEmotes =
@ -290,18 +290,6 @@ abstract class ChatAssetsStoreBase with Store {
await Future.wait(futures); await Future.wait(futures);
} }
/// Whether any shared chat participant assets have been merged in.
bool get hasLoadedSharedChatAssets => _loadedSharedChannelIds.isNotEmpty;
/// Clears tracking of merged shared chat participant assets so that leaving
/// and later re-entering a shared chat session re-fetches cleanly. The caller
/// must re-fetch this channel's own assets afterwards (which replaces the
/// merged emote/badge maps). Must run within a MobX action.
void resetSharedChatAssets() {
_loadedSharedChannelIds.clear();
sharedSevenTvSetIds.clear();
}
/// Load and cache shared chat participants for a broadcaster. /// Load and cache shared chat participants for a broadcaster.
/// Returns the list of new participant broadcaster IDs (deduped). /// Returns the list of new participant broadcaster IDs (deduped).
@action @action
@ -455,19 +443,5 @@ abstract class ChatAssetsStoreBase with Store {
} }
} }
/// Adds a channel emote (e.g., from a live 7TV event).
/// Mutates the observable `_channelEmoteToObject` so `emoteToObject` recomputes.
@action
void addChannelEmote(Emote emote) {
_channelEmoteToObject[emote.name] = emote;
}
/// Removes a channel emote by name (e.g., from a live 7TV event).
/// Mutates the observable `_channelEmoteToObject` so `emoteToObject` recomputes.
@action
void removeChannelEmote(String emoteName) {
_channelEmoteToObject.remove(emoteName);
}
void dispose() => _disposeReaction(); void dispose() => _disposeReaction();
} }

View file

@ -320,35 +320,6 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store {
); );
} }
late final _$ChatAssetsStoreBaseActionController = ActionController(
name: 'ChatAssetsStoreBase',
context: context,
);
@override
void addChannelEmote(Emote emote) {
final _$actionInfo = _$ChatAssetsStoreBaseActionController.startAction(
name: 'ChatAssetsStoreBase.addChannelEmote',
);
try {
return super.addChannelEmote(emote);
} finally {
_$ChatAssetsStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void removeChannelEmote(String emoteName) {
final _$actionInfo = _$ChatAssetsStoreBaseActionController.startAction(
name: 'ChatAssetsStoreBase.removeChannelEmote',
);
try {
return super.removeChannelEmote(emoteName);
} finally {
_$ChatAssetsStoreBaseActionController.endAction(_$actionInfo);
}
}
@override @override
String toString() { String toString() {
return ''' return '''

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

File diff suppressed because it is too large Load diff

View file

@ -1,15 +1,9 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:frosty/apis/bttv_api.dart'; import 'package:frosty/apis/bttv_api.dart';
import 'package:frosty/apis/ffz_api.dart'; import 'package:frosty/apis/ffz_api.dart';
import 'package:frosty/apis/seventv_api.dart'; import 'package:frosty/apis/seventv_api.dart';
import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/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/details/chat_details_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_interaction_pause.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart';
@ -56,10 +50,6 @@ class ChatTabInfo {
/// The ChatStore for this tab. Null until the tab is activated (lazy loading). /// The ChatStore for this tab. Null until the tab is activated (lazy loading).
ChatStore? chatStore; ChatStore? chatStore;
/// The id of the most recent message visible the last time the user viewed
/// this tab. Used to compute unread state — null means "no baseline yet".
String? lastSeenMessageId;
/// Whether this is the primary tab (first tab, cannot be removed). /// Whether this is the primary tab (first tab, cannot be removed).
final bool isPrimary; final bool isPrimary;
@ -75,14 +65,6 @@ class ChatTabInfo {
}); });
} }
/// A message paired with its source ChatStore for merged view rendering.
class MergedMessage {
final IRCMessage ircMessage;
final ChatStore chatStore;
const MergedMessage({required this.ircMessage, required this.chatStore});
}
/// Store that manages multiple chat tabs. /// Store that manages multiple chat tabs.
class ChatTabsStore = ChatTabsStoreBase with _$ChatTabsStore; class ChatTabsStore = ChatTabsStoreBase with _$ChatTabsStore;
@ -90,9 +72,6 @@ abstract class ChatTabsStoreBase with Store {
/// Maximum number of tabs allowed. /// Maximum number of tabs allowed.
static const maxTabs = 10; static const maxTabs = 10;
/// Max messages to render in merged view when autoscrolling.
static const _mergedRenderLimit = 100;
/// API services needed for creating ChatStore instances. /// API services needed for creating ChatStore instances.
final TwitchApi twitchApi; final TwitchApi twitchApi;
final BTTVApi bttvApi; final BTTVApi bttvApi;
@ -129,175 +108,6 @@ abstract class ChatTabsStoreBase with Store {
@computed @computed
bool get showTabBar => _tabs.length > 1; bool get showTabBar => _tabs.length > 1;
/// Whether merged chat mode is active (session-only, not persisted).
@observable
var mergedMode = false;
/// Channel profiles for badge rendering in merged view.
final _tabChannelProfiles = ObservableMap<String, UserTwitch>();
/// Scroll controller for the merged chat view.
final mergedScrollController = ScrollController();
/// Whether the merged view should auto-scroll.
@readonly
var _mergedAutoScroll = true;
/// Timer-driven rendered messages for merged view.
/// Updated every 200ms to match normal chat's batched flush cadence,
/// instead of reacting to each per-tab ObservableList mutation.
@readonly
var _mergedRenderedMessages = <MergedMessage>[];
/// 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;
/// Total message count across all tabs when the snapshot was taken.
int _mergedSnapshotTotalCount = 0;
/// Combined channel-to-user map for merged view badge rendering.
@computed
Map<String, UserTwitch> get mergedChannelIdToUserTwitch {
final merged = <String, UserTwitch>{};
for (final tab in _tabs) {
if (tab.chatStore != null) {
merged.addAll(tab.chatStore!.assetsStore.channelIdToUserTwitch);
}
}
merged.addAll(_tabChannelProfiles);
return merged;
}
static int _compareByTimestamp(MergedMessage a, MergedMessage b) {
final aTs = int.tryParse(a.ircMessage.tags['tmi-sent-ts'] ?? '') ?? 0;
final bTs = int.tryParse(b.ircMessage.tags['tmi-sent-ts'] ?? '') ?? 0;
return aTs.compareTo(bTs);
}
/// Adds messages from [store] to [out], deduplicating by message ID
/// (handles Twitch shared chat where the same message arrives on multiple
/// IRC connections). Includes messageBuffer only when the tab's own
/// autoScroll is off (paused tabs whose timer won't flush).
void _collectMessages(
ChatStore store,
List<MergedMessage> out,
Set<String> seenIds, {
int? tailCount,
}) {
final msgs = store.messages;
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;
out.add(MergedMessage(ircMessage: msgs[i], chatStore: store));
}
// Only read messageBuffer for paused tabs — their timer won't flush
// so these messages would otherwise be invisible in merged view.
if (!store.autoScroll) {
for (final msg in store.messageBuffer) {
final id = msg.tags['id'];
if (id != null && !seenIds.add(id)) continue;
out.add(MergedMessage(ircMessage: msg, chatStore: store));
}
}
}
/// Collects, sorts, and caps all messages from all tabs.
List<MergedMessage> _computeAllMergedMessages() {
final allMessages = <MergedMessage>[];
final seenIds = <String>{};
for (final tab in _tabs) {
if (tab.chatStore == null) continue;
_collectMessages(tab.chatStore!, allMessages, seenIds);
}
allMessages.sort(_compareByTimestamp);
return allMessages;
}
/// Fast path: only collects the tail of each tab's messages for autoscroll.
/// Instead of sorting all 50k messages, sorts at most k*100 items.
List<MergedMessage> _computeRecentMergedMessages() {
final recent = <MergedMessage>[];
final seenIds = <String>{};
for (final tab in _tabs) {
if (tab.chatStore == null) continue;
_collectMessages(
tab.chatStore!,
recent,
seenIds,
tailCount: _mergedRenderLimit,
);
}
recent.sort(_compareByTimestamp);
if (recent.length > _mergedRenderLimit) {
return recent.sublist(recent.length - _mergedRenderLimit);
}
return recent;
}
/// Interleaved messages from all activated tabs, sorted by timestamp.
/// During autoscroll, returns timer-driven [_mergedRenderedMessages] which
/// updates every 200ms — matching normal chat's batched flush cadence.
/// When scrolled up, returns a frozen snapshot.
@computed
List<MergedMessage> get mergedMessages {
if (!_mergedAutoScroll && _mergedSnapshot != null) {
return _mergedSnapshot!;
}
return _mergedRenderedMessages;
}
/// 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;
_mergedRenderedMessages = _computeRecentMergedMessages();
}
/// Count of new messages since the user scrolled up (for "N new messages").
/// Total messages (messages + messageBuffer) across all tabs.
int _totalMessageCount() {
var total = 0;
for (final tab in _tabs) {
if (tab.chatStore != null) {
total +=
tab.chatStore!.messages.length +
tab.chatStore!.messageBuffer.length;
}
}
return total;
}
@computed
int get mergedBufferCount {
if (!_mergedAutoScroll && _mergedSnapshot != null) {
final diff = _totalMessageCount() - _mergedSnapshotTotalCount;
return diff > 0 ? diff : 0;
}
return 0;
}
/// Public getter for the tabs list. /// Public getter for the tabs list.
List<ChatTabInfo> get tabs => _tabs; List<ChatTabInfo> get tabs => _tabs;
@ -314,9 +124,7 @@ abstract class ChatTabsStoreBase with Store {
required String primaryDisplayName, required String primaryDisplayName,
}) { }) {
// Enable wakelock once for all chat tabs // Enable wakelock once for all chat tabs
if (settingsStore.keepScreenAwake) { WakelockPlus.enable();
WakelockPlus.enable();
}
// Initialize with the primary channel tab // Initialize with the primary channel tab
_addPrimaryTab( _addPrimaryTab(
@ -329,26 +137,6 @@ abstract class ChatTabsStoreBase with Store {
if (settingsStore.persistChatTabs) { if (settingsStore.persistChatTabs) {
_restoreSecondaryTabs(primaryChannelId: primaryChannelId); _restoreSecondaryTabs(primaryChannelId: primaryChannelId);
} }
// Set up merged scroll listener once (reused across toggle cycles)
mergedScrollController.addListener(() {
if (!mergedScrollController.hasClients) return;
runInAction(() {
if (mergedScrollController.position.pixels <= 0) {
if (!_mergedAutoScroll) {
_mergedSnapshot = null;
_mergedAutoScroll = true;
}
} else if (mergedScrollController.position.pixels > 0) {
if (_mergedAutoScroll) {
// Capture snapshot so the list stays frozen while scrolling
_mergedSnapshot = _computeAllMergedMessages();
_mergedSnapshotTotalCount = _totalMessageCount();
_mergedAutoScroll = false;
}
}
});
});
} }
/// Creates a ChatStore for a given channel. /// Creates a ChatStore for a given channel.
@ -378,62 +166,6 @@ abstract class ChatTabsStoreBase with Store {
); );
} }
/// Toggles merged chat mode. Only already-activated tabs are included;
/// tapping a lazy tab in merged mode activates it and adds it to the merge.
@action
void toggleMergedMode() {
mergedMode = !mergedMode;
if (mergedMode) {
// Reset scroll state
_mergedAutoScroll = true;
_mergedSnapshot = null;
// Fetch channel profiles for activated tabs only
for (final tab in _tabs) {
if (tab.chatStore != null) {
_fetchTabChannelProfile(tab);
}
}
// Start render timer — compute immediately then every 200ms
_refreshMergedMessages();
_mergedRenderTimer?.cancel();
_mergedRenderTimer = Timer.periodic(
const Duration(milliseconds: 200),
(_) => _refreshMergedMessages(),
);
} else {
_mergedRenderTimer?.cancel();
_mergedRenderTimer = null;
_mergedRenderedMessages = [];
}
}
/// Re-enables auto-scroll and jumps to the latest message in merged view.
@action
void resumeMergedScroll() {
_mergedSnapshot = null;
_mergedAutoScroll = true;
// Refresh immediately so the list shows latest messages before the
// next timer tick.
_refreshMergedMessages();
mergedScrollController.jumpTo(0);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mergedScrollController.hasClients) {
mergedScrollController.jumpTo(0);
}
});
}
/// Fetches the channel profile for badge rendering in merged view.
Future<void> _fetchTabChannelProfile(ChatTabInfo tab) async {
if (_tabChannelProfiles.containsKey(tab.channelId)) return;
try {
final user = await twitchApi.getUser(id: tab.channelId);
_tabChannelProfiles[tab.channelId] = user;
} catch (e) {
debugPrint('Failed to fetch profile for ${tab.channelLogin}: $e');
}
}
/// Activates a tab by creating its ChatStore if not already activated. /// Activates a tab by creating its ChatStore if not already activated.
@action @action
void activateTab(int index) { void activateTab(int index) {
@ -542,14 +274,10 @@ abstract class ChatTabsStoreBase with Store {
), ),
); );
// Switch to the new tab, snapshotting the outgoing tab's lastSeenMessageId. // Switch to the new tab (this will activate it)
final newIndex = _tabs.length - 1; final newIndex = _tabs.length - 1;
setActiveTab(newIndex); activateTab(newIndex);
activeTabIndex = newIndex;
// If in merged mode, fetch the new tab's channel profile
if (mergedMode) {
_fetchTabChannelProfile(_tabs[newIndex]);
}
// Sync to settings for persistence // Sync to settings for persistence
_syncSecondaryTabsToSettings(); _syncSecondaryTabsToSettings();
@ -557,39 +285,6 @@ abstract class ChatTabsStoreBase with Store {
return true; return true;
} }
/// Deactivates a tab by disposing its ChatStore without removing the tab.
/// The tab stays in the tab bar but appears dimmed. Tapping reactivates it.
@action
void deactivateTab(int index) {
if (index < 0 || index >= _tabs.length) return;
final tab = _tabs[index];
if (tab.isPrimary) return;
if (tab.chatStore == null) return;
// If this is the active tab, switch to primary
if (index == activeTabIndex) {
activeTabIndex = 0;
}
// Clean up merged mode state
if (mergedMode) {
_tabChannelProfiles.remove(tab.channelId);
_mergedSnapshot = null;
}
// Dispose the ChatStore (closes IRC connection)
tab.chatStore!.dispose();
tab.chatStore = null;
// Exit merged mode if fewer than 2 tabs remain activated
if (mergedMode && _tabs.where((t) => t.chatStore != null).length < 2) {
mergedMode = false;
_mergedRenderTimer?.cancel();
_mergedRenderTimer = null;
_mergedRenderedMessages = [];
}
}
/// Removes the tab at the given index. /// Removes the tab at the given index.
/// Returns true if removed, false if primary tab or invalid index. /// Returns true if removed, false if primary tab or invalid index.
@action @action
@ -604,25 +299,12 @@ abstract class ChatTabsStoreBase with Store {
return false; return false;
} }
// Clean up merged mode state
_tabChannelProfiles.remove(_tabs[index].channelId);
// Clear stale snapshot that may reference this tab's disposed ChatStore
_mergedSnapshot = null;
// Dispose the ChatStore before removing (if activated) // Dispose the ChatStore before removing (if activated)
_tabs[index].chatStore?.dispose(); _tabs[index].chatStore?.dispose();
// Remove the tab // Remove the tab
_tabs.removeAt(index); _tabs.removeAt(index);
// Disable merged mode if only 1 tab remains
if (_tabs.length <= 1 && mergedMode) {
mergedMode = false;
_mergedRenderTimer?.cancel();
_mergedRenderTimer = null;
_mergedRenderedMessages = [];
}
// Adjust active index if necessary // Adjust active index if necessary
if (activeTabIndex >= _tabs.length) { if (activeTabIndex >= _tabs.length) {
activeTabIndex = _tabs.length - 1; activeTabIndex = _tabs.length - 1;
@ -646,8 +328,10 @@ abstract class ChatTabsStoreBase with Store {
// Primary tab (index 0) cannot be moved, and nothing can move before it // Primary tab (index 0) cannot be moved, and nothing can move before it
if (oldIndex == 0 || newIndex == 0) return; if (oldIndex == 0 || newIndex == 0) return;
// onReorderItem already adjusts newIndex for the removed item, so no // ReorderableListView passes newIndex as if item was already removed
// manual off-by-one correction is needed here. if (oldIndex < newIndex) {
newIndex -= 1;
}
if (oldIndex == newIndex) return; if (oldIndex == newIndex) return;
final tab = _tabs.removeAt(oldIndex); final tab = _tabs.removeAt(oldIndex);
@ -665,54 +349,12 @@ abstract class ChatTabsStoreBase with Store {
_syncSecondaryTabsToSettings(); _syncSecondaryTabsToSettings();
} }
/// Returns the last message id in [messages] that has a non-null tag id,
/// or null if no such message exists. Skips system messages without ids.
String? _latestMessageId(List<IRCMessage> messages) {
for (var i = messages.length - 1; i >= 0; i--) {
final id = messages[i].tags['id'];
if (id != null) return id;
}
return null;
}
/// True if [index] has unread messages since the user last viewed it.
/// Always false for the active tab and in merged mode.
bool hasUnreadMessages(int index) {
if (mergedMode) return false;
if (index == activeTabIndex) return false;
if (index < 0 || index >= _tabs.length) return false;
final tab = _tabs[index];
final store = tab.chatStore;
if (store == null) return false;
final latest = _latestMessageId(store.messages);
if (latest == null) return false;
return latest != tab.lastSeenMessageId;
}
/// Sets the active tab to the given index. /// Sets the active tab to the given index.
///
/// When [silent] is true, the current tab's draft text and reply state are
/// preserved. Used for programmatic switches (e.g., reply/paste routing in
/// merged mode) where wiping the draft would lose user work.
@action @action
void setActiveTab(int index, {bool silent = false}) { void setActiveTab(int index) {
if (index >= 0 && index < _tabs.length) { if (index >= 0 && index < _tabs.length) {
// Snapshot the outgoing tab's most recent message id so the unread // Clear text input and emote menu when switching tabs
// dot for that tab clears, and only future messages count as unread. if (index != activeTabIndex) {
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.
if (index != activeTabIndex && !silent && !mergedMode) {
final currentStore = _tabs[activeTabIndex].chatStore; final currentStore = _tabs[activeTabIndex].chatStore;
if (currentStore != null) { if (currentStore != null) {
// Close emote menu if open // Close emote menu if open
@ -729,24 +371,16 @@ abstract class ChatTabsStoreBase with Store {
// Activate the target tab if not already activated // Activate the target tab if not already activated
activateTab(index); activateTab(index);
if (mergedMode) {
_fetchTabChannelProfile(_tabs[index]);
}
activeTabIndex = index; activeTabIndex = index;
} }
} }
/// Disposes all ChatStores and cleans up resources. /// Disposes all ChatStores and cleans up resources.
void dispose() { void dispose() {
_mergedRenderTimer?.cancel();
_mergedRenderTimer = null;
_interactionPause.reset();
for (final tab in _tabs) { for (final tab in _tabs) {
tab.chatStore?.dispose(); tab.chatStore?.dispose();
} }
_tabs.clear(); _tabs.clear();
mergedScrollController.dispose();
// Disable wakelock once after all tabs disposed // Disable wakelock once after all tabs disposed
WakelockPlus.disable(); WakelockPlus.disable();

View file

@ -56,31 +56,6 @@ mixin _$ChatTabsStore on ChatTabsStoreBase, Store {
() => super.showTabBar, () => super.showTabBar,
name: 'ChatTabsStoreBase.showTabBar', name: 'ChatTabsStoreBase.showTabBar',
)).value; )).value;
Computed<Map<String, UserTwitch>>? _$mergedChannelIdToUserTwitchComputed;
@override
Map<String, UserTwitch> get mergedChannelIdToUserTwitch =>
(_$mergedChannelIdToUserTwitchComputed ??=
Computed<Map<String, UserTwitch>>(
() => super.mergedChannelIdToUserTwitch,
name: 'ChatTabsStoreBase.mergedChannelIdToUserTwitch',
))
.value;
Computed<List<MergedMessage>>? _$mergedMessagesComputed;
@override
List<MergedMessage> get mergedMessages =>
(_$mergedMessagesComputed ??= Computed<List<MergedMessage>>(
() => super.mergedMessages,
name: 'ChatTabsStoreBase.mergedMessages',
)).value;
Computed<int>? _$mergedBufferCountComputed;
@override
int get mergedBufferCount => (_$mergedBufferCountComputed ??= Computed<int>(
() => super.mergedBufferCount,
name: 'ChatTabsStoreBase.mergedBufferCount',
)).value;
late final _$_tabsAtom = Atom( late final _$_tabsAtom = Atom(
name: 'ChatTabsStoreBase._tabs', name: 'ChatTabsStoreBase._tabs',
@ -120,109 +95,11 @@ mixin _$ChatTabsStore on ChatTabsStoreBase, Store {
}); });
} }
late final _$mergedModeAtom = Atom(
name: 'ChatTabsStoreBase.mergedMode',
context: context,
);
@override
bool get mergedMode {
_$mergedModeAtom.reportRead();
return super.mergedMode;
}
@override
set mergedMode(bool value) {
_$mergedModeAtom.reportWrite(value, super.mergedMode, () {
super.mergedMode = value;
});
}
late final _$_mergedAutoScrollAtom = Atom(
name: 'ChatTabsStoreBase._mergedAutoScroll',
context: context,
);
bool get mergedAutoScroll {
_$_mergedAutoScrollAtom.reportRead();
return super._mergedAutoScroll;
}
@override
bool get _mergedAutoScroll => mergedAutoScroll;
@override
set _mergedAutoScroll(bool value) {
_$_mergedAutoScrollAtom.reportWrite(value, super._mergedAutoScroll, () {
super._mergedAutoScroll = value;
});
}
late final _$_mergedRenderedMessagesAtom = Atom(
name: 'ChatTabsStoreBase._mergedRenderedMessages',
context: context,
);
List<MergedMessage> get mergedRenderedMessages {
_$_mergedRenderedMessagesAtom.reportRead();
return super._mergedRenderedMessages;
}
@override
List<MergedMessage> get _mergedRenderedMessages => mergedRenderedMessages;
@override
set _mergedRenderedMessages(List<MergedMessage> value) {
_$_mergedRenderedMessagesAtom.reportWrite(
value,
super._mergedRenderedMessages,
() {
super._mergedRenderedMessages = value;
},
);
}
late final _$ChatTabsStoreBaseActionController = ActionController( late final _$ChatTabsStoreBaseActionController = ActionController(
name: 'ChatTabsStoreBase', name: 'ChatTabsStoreBase',
context: context, context: context,
); );
@override
void _refreshMergedMessages() {
final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction(
name: 'ChatTabsStoreBase._refreshMergedMessages',
);
try {
return super._refreshMergedMessages();
} finally {
_$ChatTabsStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void toggleMergedMode() {
final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction(
name: 'ChatTabsStoreBase.toggleMergedMode',
);
try {
return super.toggleMergedMode();
} finally {
_$ChatTabsStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void resumeMergedScroll() {
final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction(
name: 'ChatTabsStoreBase.resumeMergedScroll',
);
try {
return super.resumeMergedScroll();
} finally {
_$ChatTabsStoreBaseActionController.endAction(_$actionInfo);
}
}
@override @override
void activateTab(int index) { void activateTab(int index) {
final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction( final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction(
@ -255,18 +132,6 @@ mixin _$ChatTabsStore on ChatTabsStoreBase, Store {
} }
} }
@override
void deactivateTab(int index) {
final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction(
name: 'ChatTabsStoreBase.deactivateTab',
);
try {
return super.deactivateTab(index);
} finally {
_$ChatTabsStoreBaseActionController.endAction(_$actionInfo);
}
}
@override @override
bool removeTab(int index) { bool removeTab(int index) {
final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction( final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction(
@ -292,12 +157,12 @@ mixin _$ChatTabsStore on ChatTabsStoreBase, Store {
} }
@override @override
void setActiveTab(int index, {bool silent = false}) { void setActiveTab(int index) {
final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction( final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction(
name: 'ChatTabsStoreBase.setActiveTab', name: 'ChatTabsStoreBase.setActiveTab',
); );
try { try {
return super.setActiveTab(index, silent: silent); return super.setActiveTab(index);
} finally { } finally {
_$ChatTabsStoreBaseActionController.endAction(_$actionInfo); _$ChatTabsStoreBaseActionController.endAction(_$actionInfo);
} }
@ -307,14 +172,10 @@ mixin _$ChatTabsStore on ChatTabsStoreBase, Store {
String toString() { String toString() {
return ''' return '''
activeTabIndex: ${activeTabIndex}, activeTabIndex: ${activeTabIndex},
mergedMode: ${mergedMode},
activeTab: ${activeTab}, activeTab: ${activeTab},
activeChatStore: ${activeChatStore}, activeChatStore: ${activeChatStore},
canAddTab: ${canAddTab}, canAddTab: ${canAddTab},
showTabBar: ${showTabBar}, showTabBar: ${showTabBar}
mergedChannelIdToUserTwitch: ${mergedChannelIdToUserTwitch},
mergedMessages: ${mergedMessages},
mergedBufferCount: ${mergedBufferCount}
'''; ''';
} }
} }

View file

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

View file

@ -11,7 +11,6 @@ import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:frosty/widgets/blurred_container.dart'; import 'package:frosty/widgets/blurred_container.dart';
import 'package:frosty/widgets/chat_input/emote_text_span_builder.dart'; import 'package:frosty/widgets/chat_input/emote_text_span_builder.dart';
import 'package:frosty/widgets/frosty_cached_network_image.dart'; import 'package:frosty/widgets/frosty_cached_network_image.dart';
import 'package:frosty/widgets/measure_size.dart';
class ChatBottomBar extends StatelessWidget { class ChatBottomBar extends StatelessWidget {
final ChatStore chatStore; final ChatStore chatStore;
@ -20,14 +19,10 @@ class ChatBottomBar extends StatelessWidget {
/// Passes this to ChatDetails to show "Add chat" option. /// Passes this to ChatDetails to show "Add chat" option.
final VoidCallback onAddChat; final VoidCallback onAddChat;
/// When set, indicates merged mode — shown in hint text (e.g., "Chat in xQc").
final String? channelDisplayName;
const ChatBottomBar({ const ChatBottomBar({
super.key, super.key,
required this.chatStore, required this.chatStore,
required this.onAddChat, required this.onAddChat,
this.channelDisplayName,
}); });
@override @override
@ -68,11 +63,9 @@ class ChatBottomBar extends StatelessWidget {
final isFullscreenOverlay = final isFullscreenOverlay =
chatStore.settings.fullScreen && context.isLandscape; chatStore.settings.fullScreen && context.isLandscape;
final effectiveChatDelay = chatStore.settings.effectiveChatDelay;
// Check if chat delay is active (for indicator only, doesn't block input) // Check if chat delay is active (for indicator only, doesn't block input)
final hasChatDelay = final hasChatDelay =
chatStore.settings.showVideo && effectiveChatDelay > 0; chatStore.settings.showVideo && chatStore.settings.chatDelay > 0;
const loginTooltipMessage = 'Log in to chat'; const loginTooltipMessage = 'Log in to chat';
@ -306,15 +299,9 @@ class ChatBottomBar extends StatelessWidget {
: isWaitingForAck : isWaitingForAck
? 'Sending...' ? 'Sending...'
: chatStore.replyingToMessage != null : chatStore.replyingToMessage != null
? channelDisplayName != null ? 'Reply'
? 'Reply in ${channelDisplayName!}'
: 'Reply'
: hasChatDelay : hasChatDelay
? channelDisplayName != null ? 'Chat (${chatStore.settings.chatDelay.toInt()}s delay)'
? 'Chat in ${channelDisplayName!} (${effectiveChatDelay.toInt()}s delay)'
: 'Chat (${effectiveChatDelay.toInt()}s delay)'
: channelDisplayName != null
? 'Chat in ${channelDisplayName!}'
: 'Chat', : 'Chat',
), ),
controller: chatStore.textController, controller: chatStore.textController,
@ -394,13 +381,6 @@ class ChatBottomBar extends StatelessWidget {
final needsBottomPadding = final needsBottomPadding =
!chatStore.assetsStore.showEmoteMenu && !isHorizontalLandscape; !chatStore.assetsStore.showEmoteMenu && !isHorizontalLandscape;
// Measured once here; the list pads itself by this height so the bar
// never overlaps messages. Shared by both container branches below.
final measuredBar = MeasureSize(
onChange: (size) => chatStore.setBottomBarHeight(size.height),
child: bottomBarContent,
);
return isFullscreenOverlay return isFullscreenOverlay
? Padding( ? Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
@ -408,7 +388,7 @@ class ChatBottomBar extends StatelessWidget {
? MediaQuery.of(context).padding.bottom ? MediaQuery.of(context).padding.bottom
: 0, : 0,
), ),
child: measuredBar, child: bottomBarContent,
) )
: BlurredContainer( : BlurredContainer(
gradientDirection: GradientDirection.down, gradientDirection: GradientDirection.down,
@ -417,7 +397,7 @@ class ChatBottomBar extends StatelessWidget {
? MediaQuery.of(context).padding.bottom ? MediaQuery.of(context).padding.bottom
: 0, : 0,
), ),
child: measuredBar, child: bottomBarContent,
); );
}, },
); );

View file

@ -5,12 +5,9 @@ import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/constants.dart'; import 'package:frosty/constants.dart';
import 'package:frosty/models/irc.dart'; import 'package:frosty/models/irc.dart';
import 'package:frosty/models/user.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart';
import 'package:frosty/screens/channel/chat/widgets/moderation_actions.dart';
import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart'; import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart'; import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -18,36 +15,16 @@ class ChatMessage extends StatelessWidget {
final IRCMessage ircMessage; final IRCMessage ircMessage;
final ChatStore chatStore; final ChatStore chatStore;
final bool isModal; final bool isModal;
final bool forceTimestamp;
final bool showReplyHeader; final bool showReplyHeader;
final bool isInReplyThread; final bool isInReplyThread;
/// Override for merged chat view — provides combined channel profiles.
final Map<String, UserTwitch>? overrideChannelIdToUserTwitch;
/// Override for merged chat view — sets the active/send-target channel ID.
final String? overrideCurrentChannelId;
/// In merged mode, the active tab's ChatStore for notifications and emote
/// menu. Composer actions (reply/paste) use [onActivateSourceTab] to switch
/// to the source tab first, then operate on [chatStore] directly.
final ChatStore? inputChatStore;
/// In merged mode, switches the active tab to this message's source channel.
final VoidCallback? onActivateSourceTab;
const ChatMessage({ const ChatMessage({
super.key, super.key,
required this.ircMessage, required this.ircMessage,
required this.chatStore, required this.chatStore,
this.isModal = false, this.isModal = false,
this.forceTimestamp = false,
this.showReplyHeader = true, this.showReplyHeader = true,
this.isInReplyThread = false, this.isInReplyThread = false,
this.overrideChannelIdToUserTwitch,
this.overrideCurrentChannelId,
this.inputChatStore,
this.onActivateSourceTab,
}); });
void onTapName(BuildContext context) { void onTapName(BuildContext context) {
@ -68,7 +45,6 @@ class ChatMessage extends StatelessWidget {
username: ircMessage.user!, username: ircMessage.user!,
userId: ircMessage.tags['user-id']!, userId: ircMessage.tags['user-id']!,
displayName: ircMessage.tags['display-name']!, displayName: ircMessage.tags['display-name']!,
onActivateSourceTab: onActivateSourceTab,
), ),
); );
} }
@ -88,33 +64,23 @@ class ChatMessage extends StatelessWidget {
username: user.login, username: user.login,
userId: user.id, userId: user.id,
displayName: user.displayName, displayName: user.displayName,
onActivateSourceTab: onActivateSourceTab,
), ),
); );
} }
}); });
} }
/// The store to use for notifications and emote menu in merged mode.
ChatStore get _effectiveInputStore => inputChatStore ?? chatStore;
Future<void> copyMessage() async { Future<void> copyMessage() async {
await Clipboard.setData(ClipboardData(text: ircMessage.message ?? '')); await Clipboard.setData(ClipboardData(text: ircMessage.message ?? ''));
_effectiveInputStore.updateNotification('Message copied'); chatStore.updateNotification('Message copied');
} }
void onLongPressMessage(BuildContext context, TextStyle defaultTextStyle) { void onLongPressMessage(BuildContext context, TextStyle defaultTextStyle) {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
// Allow the full menu for live messages and for ones marked deleted/cleared
// by a moderation action — those keep their tags (user-id, id), so a
// moderator can still act on them (e.g. remove a timeout). Everything else
// (notices, system messages) just copies.
if (ircMessage.command != Command.privateMessage && if (ircMessage.command != Command.privateMessage &&
ircMessage.command != Command.userState && ircMessage.command != Command.userState) {
ircMessage.command != Command.clearChat &&
ircMessage.command != Command.clearMessage) {
copyMessage(); copyMessage();
return; return;
} }
@ -137,14 +103,9 @@ class ChatMessage extends StatelessWidget {
badgeScale: chatStore.settings.badgeScale, badgeScale: chatStore.settings.badgeScale,
launchExternal: chatStore.settings.launchUrlExternal, launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType, timestamp: chatStore.settings.timestampType,
forceTimestamp: forceTimestamp,
showHistoricalTimestamps:
chatStore.settings.showHistoricalTimestamps,
channelIdToUserTwitch: channelIdToUserTwitch:
overrideChannelIdToUserTwitch ??
chatStore.assetsStore.channelIdToUserTwitch, chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId: currentChannelId: chatStore.channelId,
overrideCurrentChannelId ?? chatStore.channelId,
), ),
style: defaultTextStyle, style: defaultTextStyle,
), ),
@ -163,14 +124,13 @@ class ChatMessage extends StatelessWidget {
onTap: () async { onTap: () async {
await copyMessage(); await copyMessage();
final effectiveChatDelay = chatStore.settings.effectiveChatDelay;
final hasChatDelay = final hasChatDelay =
chatStore.settings.showVideo && effectiveChatDelay > 0; chatStore.settings.showVideo &&
chatStore.settings.chatDelay > 0;
if (hasChatDelay) { if (hasChatDelay) {
_effectiveInputStore.updateNotification( chatStore.updateNotification(
'Chatting is disabled due to message delay (${effectiveChatDelay.toInt()}s)', 'Chatting is disabled due to message delay (${chatStore.settings.chatDelay.toInt()}s)',
); );
if (context.mounted) { if (context.mounted) {
Navigator.pop(context); Navigator.pop(context);
@ -178,10 +138,6 @@ class ChatMessage extends StatelessWidget {
return; 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 // Paste the copied message into the text controller
chatStore.textController.text = ircMessage.message ?? ''; chatStore.textController.text = ircMessage.message ?? '';
chatStore.safeRequestFocus(); chatStore.safeRequestFocus();
@ -194,11 +150,6 @@ class ChatMessage extends StatelessWidget {
), ),
ListTile( ListTile(
onTap: () { 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.replyingToMessage = ircMessage;
chatStore.safeRequestFocus(); chatStore.safeRequestFocus();
Navigator.pop(context); Navigator.pop(context);
@ -206,14 +157,6 @@ class ChatMessage extends StatelessWidget {
leading: const Icon(Icons.reply), leading: const Icon(Icons.reply),
title: const Text('Reply to message'), title: const Text('Reply to message'),
), ),
if (ircMessage.tags['user-id'] != null)
ModerationActions(
chatStore: chatStore,
targetUserId: ircMessage.tags['user-id']!,
targetName:
ircMessage.tags['display-name'] ?? ircMessage.user ?? '',
messageId: ircMessage.tags['id'],
),
], ],
), ),
); );
@ -257,14 +200,9 @@ class ChatMessage extends StatelessWidget {
badgeScale: chatStore.settings.badgeScale, badgeScale: chatStore.settings.badgeScale,
launchExternal: chatStore.settings.launchUrlExternal, launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType, timestamp: chatStore.settings.timestampType,
forceTimestamp: forceTimestamp,
showHistoricalTimestamps:
chatStore.settings.showHistoricalTimestamps,
channelIdToUserTwitch: channelIdToUserTwitch:
overrideChannelIdToUserTwitch ??
chatStore.assetsStore.channelIdToUserTwitch, chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId: currentChannelId: chatStore.channelId,
overrideCurrentChannelId ?? chatStore.channelId,
), ),
), ),
); );
@ -314,7 +252,7 @@ class ChatMessage extends StatelessWidget {
), ),
); );
} else if (shouldHighlightFirstMessage) { } else if (shouldHighlightFirstMessage) {
highlightColor = context.frostyColors.highlightedMessage; highlightColor = Colors.purple;
messageHeaderIcon = Icon( messageHeaderIcon = Icon(
Icons.auto_awesome_rounded, Icons.auto_awesome_rounded,
size: messageHeaderIconSize, size: messageHeaderIconSize,
@ -328,7 +266,7 @@ class ChatMessage extends StatelessWidget {
), ),
); );
} else if (shouldHighlightMessage) { } else if (shouldHighlightMessage) {
highlightColor = context.frostyColors.highlightedMessage; highlightColor = const Color(0xff9146ff);
messageHeader = Text( messageHeader = Text(
'Highlighted message', 'Highlighted message',
style: TextStyle( style: TextStyle(
@ -339,9 +277,7 @@ class ChatMessage extends StatelessWidget {
} }
// If user is being mentioned in the message, highlight it red. // If user is being mentioned in the message, highlight it red.
if (ircMessage.mention == true) { if (ircMessage.mention == true) highlightColor = Colors.red;
highlightColor = context.colorScheme.error;
}
if (messageHeader != null) { if (messageHeader != null) {
renderMessage = Column( renderMessage = Column(
@ -416,14 +352,9 @@ class ChatMessage extends StatelessWidget {
}, },
launchExternal: chatStore.settings.launchUrlExternal, launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType, timestamp: chatStore.settings.timestampType,
forceTimestamp: forceTimestamp,
showHistoricalTimestamps:
chatStore.settings.showHistoricalTimestamps,
channelIdToUserTwitch: channelIdToUserTwitch:
overrideChannelIdToUserTwitch ??
chatStore.assetsStore.channelIdToUserTwitch, chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId: currentChannelId: chatStore.channelId,
overrideCurrentChannelId ?? chatStore.channelId,
), ),
), ),
), ),
@ -470,7 +401,7 @@ class ChatMessage extends StatelessWidget {
break; break;
case Command.userNotice: case Command.userNotice:
if (chatStore.settings.showUserNotices) { if (chatStore.settings.showUserNotices) {
highlightColor = context.frostyColors.highlightedMessage; highlightColor = const Color(0xff9146ff);
Widget? messageHeaderIcon; Widget? messageHeaderIcon;
Widget? messageHeader; Widget? messageHeader;
@ -512,14 +443,10 @@ class ChatMessage extends StatelessWidget {
messageHeaderIcon = Icon( messageHeaderIcon = Icon(
Icons.campaign_rounded, Icons.campaign_rounded,
size: messageHeaderIconSize, size: messageHeaderIconSize,
color: messageHeaderTextColor,
); );
messageHeader = Text( messageHeader = const Text(
'Announcement', 'Announcement',
style: TextStyle( style: TextStyle(fontWeight: messageHeaderFontWeight),
fontWeight: messageHeaderFontWeight,
color: messageHeaderTextColor,
),
); );
} }
@ -548,14 +475,9 @@ class ChatMessage extends StatelessWidget {
badgeScale: chatStore.settings.badgeScale, badgeScale: chatStore.settings.badgeScale,
launchExternal: chatStore.settings.launchUrlExternal, launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType, timestamp: chatStore.settings.timestampType,
forceTimestamp: forceTimestamp,
showHistoricalTimestamps:
chatStore.settings.showHistoricalTimestamps,
channelIdToUserTwitch: channelIdToUserTwitch:
overrideChannelIdToUserTwitch ??
chatStore.assetsStore.channelIdToUserTwitch, chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId: currentChannelId: chatStore.channelId,
overrideCurrentChannelId ?? chatStore.channelId,
), ),
), ),
), ),
@ -626,24 +548,15 @@ class ChatMessage extends StatelessWidget {
child: dividedMessage, child: dividedMessage,
); );
final shouldFade = chatStore.shouldFadeMessage(
sourceChannelId: ircMessage.sourceChannelId,
overrideCurrentChannelId: overrideCurrentChannelId,
);
final fadedMessage = shouldFade
? Opacity(opacity: 0.55, child: coloredMessage)
: coloredMessage;
final finalMessage = InkWell( final finalMessage = InkWell(
onTap: () { onTap: () {
FocusScope.of(context).unfocus(); FocusScope.of(context).unfocus();
if (_effectiveInputStore.assetsStore.showEmoteMenu) { if (chatStore.assetsStore.showEmoteMenu) {
_effectiveInputStore.assetsStore.showEmoteMenu = false; chatStore.assetsStore.showEmoteMenu = false;
} }
}, },
onLongPress: () => onLongPressMessage(context, defaultTextStyle), onLongPress: () => onLongPressMessage(context, defaultTextStyle),
child: fadedMessage, child: coloredMessage,
); );
return finalMessage; return finalMessage;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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