Compare commits
1 commit
main
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c63fecb9b0 |
257 changed files with 8706 additions and 33520 deletions
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
CLIENT_ID=
|
||||
SECRET=
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
208
.github/copilot-instructions.md
vendored
Normal file
208
.github/copilot-instructions.md
vendored
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
# 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
|
||||
|
||||
### Code Generation (REQUIRED)
|
||||
|
||||
All MobX stores and JSON models require code generation. **Always run after any MobX or model changes:**
|
||||
|
||||
```bash
|
||||
flutter packages pub run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
**Alternative commands:**
|
||||
|
||||
- `flutter packages pub run build_runner build` - Generate code once
|
||||
- `flutter packages pub 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` and `SettingsStore` in `lib/screens/settings/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()`)
|
||||
|
||||
## 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`
|
||||
|
||||
**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
|
||||
|
||||
Use `Future.error()` for API failures, not exceptions.
|
||||
|
||||
## MobX Store Implementation
|
||||
|
||||
### Store Pattern
|
||||
|
||||
All stores follow `StoreBase with _$StoreName` pattern:
|
||||
|
||||
- Generate `.g.dart` files with `flutter packages pub 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
|
||||
|
||||
When working with this codebase, always consider MobX code generation requirements and the shared HTTP client pattern for API efficiency.
|
||||
51
.github/workflows/ci.yml
vendored
51
.github/workflows/ci.yml
vendored
|
|
@ -3,57 +3,18 @@ on:
|
|||
push:
|
||||
branches: [main]
|
||||
jobs:
|
||||
# Linux runner: analyze + test once (both are OS-independent) and compile a
|
||||
# release APK to catch release-only breakage. Debug-signed via the build.gradle
|
||||
# fallback (ALLOW_DEBUG_SIGNED_RELEASE) — no signing secrets needed.
|
||||
android:
|
||||
name: Analyze, test, and build Android
|
||||
runs-on: ubuntu-latest
|
||||
test:
|
||||
name: Check and test on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
# Match the JDK the release workflows build with, so this compile check
|
||||
# faithfully catches what would break a real release.
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version-file: pubspec.yaml
|
||||
cache: true
|
||||
- run: flutter doctor -v
|
||||
- run: flutter pub get
|
||||
# Fail if committed generated files (*.g.dart) are stale — CI never runs
|
||||
# build_runner otherwise, so forgetting to regenerate after editing a MobX
|
||||
# store or @JsonSerializable model slips past analyze/test/build.
|
||||
- run: dart run build_runner build --delete-conflicting-outputs
|
||||
- name: Verify generated files are up to date
|
||||
run: |
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo '::error::Generated files are out of date. Run `dart run build_runner build --delete-conflicting-outputs` and commit the result.'
|
||||
git status --porcelain
|
||||
git diff
|
||||
exit 1
|
||||
fi
|
||||
- run: flutter analyze
|
||||
- run: flutter test
|
||||
- run: flutter build apk --release
|
||||
env:
|
||||
ALLOW_DEBUG_SIGNED_RELEASE: '1'
|
||||
# macOS runner: release-compile iOS (no codesign, no secrets) — the only thing
|
||||
# that must run on macOS. analyze/test already covered on the Linux job.
|
||||
ios:
|
||||
name: Build iOS
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
# Match release workflows and avoid GitHub's lagging default Xcode.
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: latest-stable
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version-file: pubspec.yaml
|
||||
cache: true
|
||||
- run: flutter pub get
|
||||
- run: flutter build ios --release --no-codesign
|
||||
|
|
|
|||
50
.github/workflows/gh-release.yml
vendored
50
.github/workflows/gh-release.yml
vendored
|
|
@ -32,7 +32,6 @@ jobs:
|
|||
- name: Set up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version-file: pubspec.yaml
|
||||
cache: true
|
||||
|
||||
# Decode and create the upload-keystore.jks file, used for signing the app.
|
||||
|
|
@ -52,54 +51,11 @@ jobs:
|
|||
run: |
|
||||
flutter build apk --dart-define CLIENT_ID=$CLIENT_ID --dart-define SECRET=$SECRET
|
||||
mv build/app/outputs/flutter-apk/app-release.apk frosty-${{ github.ref_name }}.apk
|
||||
|
||||
# Extract release notes from assets/release-notes.md for the current version.
|
||||
- name: Extract release notes
|
||||
id: release_notes
|
||||
run: |
|
||||
VERSION="${{ github.ref_name }}"
|
||||
RELEASE_NOTES_FILE="assets/release-notes.md"
|
||||
|
||||
# Extract the section for this version using awk
|
||||
# Starts at "## vX.X.X", skips date line, stops at next "---" or "## v" header
|
||||
NOTES=$(awk -v version="$VERSION" '
|
||||
BEGIN { found=0; output="" }
|
||||
$0 ~ "^## " version "$" { found=1; next }
|
||||
found && /^[A-Z][a-z]+ [0-9]+, [0-9]+$/ { next }
|
||||
found && /^---$/ { exit }
|
||||
found && /^## v[0-9]+\.[0-9]+\.[0-9]+$/ { exit }
|
||||
found { output = output $0 "\n" }
|
||||
END { print output }
|
||||
' "$RELEASE_NOTES_FILE")
|
||||
|
||||
# Clean up "on GitHub" suffix (redundant since we're on GitHub)
|
||||
NOTES=$(echo "$NOTES" | sed 's/ on GitHub)/)/')
|
||||
|
||||
# Trim leading/trailing whitespace
|
||||
NOTES=$(echo "$NOTES" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
||||
|
||||
if [ -n "$NOTES" ]; then
|
||||
# Get the previous tag for the Full Changelog link
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 "$VERSION^" 2>/dev/null || echo "")
|
||||
|
||||
echo "found=true" >> $GITHUB_OUTPUT
|
||||
echo "body<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$NOTES" >> $GITHUB_OUTPUT
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
echo "" >> $GITHUB_OUTPUT
|
||||
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREV_TAG}...${VERSION}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "found=false" >> $GITHUB_OUTPUT
|
||||
echo "::warning::No release notes found for $VERSION in $RELEASE_NOTES_FILE"
|
||||
fi
|
||||
|
||||
|
||||
# Create a draft release on GitHub.
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
draft: true
|
||||
generate_release_notes: ${{ steps.release_notes.outputs.found != 'true' }}
|
||||
body: ${{ steps.release_notes.outputs.found == 'true' && steps.release_notes.outputs.body || '' }}
|
||||
generate_release_notes: true
|
||||
files: frosty-${{ github.ref_name }}.apk
|
||||
|
|
|
|||
26
.github/workflows/pr-check.yml
vendored
26
.github/workflows/pr-check.yml
vendored
|
|
@ -8,36 +8,10 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
# Match the JDK the release workflows build with, so the release-mode APK
|
||||
# check below faithfully catches what would break a real release.
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version-file: pubspec.yaml
|
||||
cache: true
|
||||
- run: flutter doctor -v
|
||||
- run: flutter pub get
|
||||
# Fail if committed generated files (*.g.dart) are stale — CI never runs
|
||||
# build_runner otherwise, so forgetting to regenerate after editing a MobX
|
||||
# store or @JsonSerializable model slips past analyze/test/build.
|
||||
- run: dart run build_runner build --delete-conflicting-outputs
|
||||
- name: Verify generated files are up to date
|
||||
run: |
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo '::error::Generated files are out of date. Run `dart run build_runner build --delete-conflicting-outputs` and commit the result.'
|
||||
git status --porcelain
|
||||
git diff
|
||||
exit 1
|
||||
fi
|
||||
- run: flutter analyze
|
||||
- run: flutter test
|
||||
# Release-mode compile check (no signing secrets needed — Android falls back
|
||||
# to debug signing via ALLOW_DEBUG_SIGNED_RELEASE). Catches release-only
|
||||
# breakage that debug builds and tests miss (AOT compile, icon tree-shaking,
|
||||
# plugin/Gradle release incompatibility).
|
||||
- run: flutter build apk --release
|
||||
env:
|
||||
ALLOW_DEBUG_SIGNED_RELEASE: '1'
|
||||
|
|
|
|||
126
.github/workflows/release-dryrun.yml
vendored
126
.github/workflows/release-dryrun.yml
vendored
|
|
@ -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
|
||||
8
.github/workflows/store-release.yml
vendored
8
.github/workflows/store-release.yml
vendored
|
|
@ -35,7 +35,7 @@ jobs:
|
|||
- name: Set up Ruby and Bundler
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 3.3
|
||||
ruby-version: 3.2
|
||||
bundler-cache: true
|
||||
|
||||
# Setup Java so that we can build the appbundle.
|
||||
|
|
@ -49,7 +49,6 @@ jobs:
|
|||
- name: Set up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version-file: pubspec.yaml
|
||||
cache: true
|
||||
|
||||
# Decode and create the upload-keystore.jks file, used for signing the app.
|
||||
|
|
@ -85,7 +84,7 @@ jobs:
|
|||
- name: Checkout Frosty repo
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# Always pick the newest production Xcode on the runner.
|
||||
# Always pick the newest production Xcode on the runner
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: latest-stable
|
||||
|
|
@ -94,14 +93,13 @@ jobs:
|
|||
- name: Set up Ruby and Bundler
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 3.3
|
||||
ruby-version: 3.2
|
||||
bundler-cache: true
|
||||
|
||||
# Setup and cache Flutter to install packages and build.
|
||||
- name: Set up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version-file: pubspec.yaml
|
||||
cache: true
|
||||
|
||||
# Setup the iOS project with environment variables without signing (building and signing will happen in fastlane).
|
||||
|
|
|
|||
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -21,7 +21,7 @@ migrate_working_dir/
|
|||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
# .vscode/
|
||||
.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
|
|
@ -44,8 +44,6 @@ app.*.map.json
|
|||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
# AGP 9 writes a problems report here even when the Gradle build dir is redirected
|
||||
/android/build/
|
||||
|
||||
# Folder containing random testing files
|
||||
random/
|
||||
|
|
@ -66,8 +64,6 @@ random/
|
|||
# potentially sensitive review information
|
||||
**/fastlane/metadata/review_information
|
||||
|
||||
# secrets
|
||||
.env
|
||||
|
||||
# ai
|
||||
.claude/settings.local.json
|
||||
.claude
|
||||
.cursor
|
||||
12
.mcp.json
12
.mcp.json
|
|
@ -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
25
.vscode/launch.json
vendored
|
|
@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
46
AGENTS.md
46
AGENTS.md
|
|
@ -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.
|
||||
132
CLAUDE.md
Normal file
132
CLAUDE.md
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
**Plan Mode - In-Depth Planning**: 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
|
||||
|
||||
**Build 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 tests (note: no test suite currently exists)
|
||||
|
||||
**Code Generation (Required for MobX stores and JSON serialization)**
|
||||
|
||||
- `flutter packages pub run build_runner build` - Generate code once
|
||||
- `flutter packages pub run build_runner watch` - Watch and regenerate code on changes
|
||||
- `flutter packages pub run build_runner build --delete-conflicting-outputs` - Force rebuild all generated files
|
||||
|
||||
**Asset Generation**
|
||||
|
||||
- `flutter pub run flutter_native_splash:create` - Generate native splash screens
|
||||
- `flutter pub run flutter_launcher_icons:main` - Generate app launcher icons
|
||||
|
||||
**Environment Variables**
|
||||
Use `--dart-define` to set environment variables (required for Twitch API access):
|
||||
|
||||
```bash
|
||||
flutter run --dart-define=clientId=YOUR_TWITCH_CLIENT_ID --dart-define=secret=YOUR_TWITCH_CLIENT_SECRET
|
||||
```
|
||||
|
||||
## 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** (provided at app root in `main.dart`):
|
||||
|
||||
- `AuthStore` - Authentication state and token management
|
||||
- `SettingsStore` - User preferences with automatic persistence
|
||||
|
||||
## HTTP Client Architecture
|
||||
|
||||
**Centralized Dio Setup**: All API services share a single Dio instance configured in `DioClient.createClient()` with connection pooling, keep-alive headers, and optimized timeouts (8s connect, 15s receive, 10s send).
|
||||
|
||||
**Authentication Interceptor**: `TwitchAuthInterceptor` automatically injects auth headers for Twitch API URLs. Updates automatically when user authentication changes.
|
||||
|
||||
**API Service Pattern**: All API services extend `BaseApiClient` for consistent error handling. Services: `TwitchApi`, `BTTVApi`, `FFZApi`, `SevenTVApi`.
|
||||
|
||||
## Authentication & Token Management
|
||||
|
||||
**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
|
||||
|
||||
**Secure Storage Cleanup**: First-run detection clears secure storage to handle Android/iOS uninstall scenarios where secure storage persists.
|
||||
|
||||
## 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();
|
||||
}
|
||||
```
|
||||
|
||||
After any changes to MobX stores or `@JsonSerializable()` models, run the build_runner to regenerate `.g.dart` files.
|
||||
|
||||
## 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**: Separate `ChatAssetsStore` manages emotes, badges, and chat-related data
|
||||
|
||||
## Code Style
|
||||
|
||||
Analysis rules enforced via `analysis_options.yaml`:
|
||||
|
||||
- `prefer_single_quotes` - Use single quotes for strings
|
||||
- `always_use_package_imports` - Package imports over relative imports
|
||||
- `require_trailing_commas` - Trailing commas on multi-line constructs
|
||||
- `prefer_final_locals` - Final for local variables
|
||||
- `directives_ordering`, `avoid_void_async`, `always_declare_return_types`
|
||||
|
||||
Generated `.g.dart` files are excluded from analysis but must be committed.
|
||||
|
||||
## Commit Convention
|
||||
|
||||
Commits follow conventional format: `refactor:`, `feat:`, `fix:`, `format:`, etc. Keep commits tightly scoped. Include generated `.g.dart` outputs in the same commit as their source changes.
|
||||
61
DESIGN.md
61
DESIGN.md
|
|
@ -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`.
|
||||
10
README.md
10
README.md
|
|
@ -85,17 +85,11 @@ For a more detailed overview, visit [frostyapp.io](https://www.frostyapp.io/).
|
|||
|
||||
3. Go to the [Twitch dev console](https://dev.twitch.tv/login) and register a new application to retrieve a **Client ID** and **Client Secret**.
|
||||
|
||||
4. Copy `.env.example` to `.env` and fill in your credentials:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
4. Use [`--dart-define`](https://dartcode.org/docs/using-dart-define-in-flutter/) to set the `clientId` and `secret` environment variables with your **Client ID** and **Client Secret**.
|
||||
|
||||
5. Run `flutter pub get` to fetch all the dependencies.
|
||||
|
||||
6. Choose an emulator or device and run the app! The included VS Code launch configurations will automatically read your `.env` file.
|
||||
|
||||
7. (Optional) Run `dart run scripts/get_twitch_token.dart` to quickly get a user auth token. Long-press the **Anonymous** account tile in the app to log in.
|
||||
6. Choose an emulator or device and run the app!
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Frosty uses [MobX](https://mobx.netlify.app/) for state management. Please refer to the documentation about code generation, otherwise your changes within MobX stores may not be applied.
|
||||
|
|
|
|||
|
|
@ -16,9 +16,6 @@ analyzer:
|
|||
- ios/**
|
||||
- android/**
|
||||
- web/**
|
||||
- windows/**
|
||||
- macos/**
|
||||
- linux/**
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@ GEM
|
|||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.9.0)
|
||||
addressable (2.8.8)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1260.0)
|
||||
aws-sdk-core (3.252.0)
|
||||
aws-partitions (1.1200.0)
|
||||
aws-sdk-core (3.240.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
|
|
@ -17,19 +17,18 @@ GEM
|
|||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.129.0)
|
||||
aws-sdk-core (~> 3, >= 3.248.0)
|
||||
aws-sdk-kms (1.118.0)
|
||||
aws-sdk-core (~> 3, >= 3.239.1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.225.1)
|
||||
aws-sdk-core (~> 3, >= 3.248.0)
|
||||
aws-sdk-s3 (1.209.0)
|
||||
aws-sdk-core (~> 3, >= 3.234.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.3.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.2)
|
||||
base64 (0.2.0)
|
||||
bigdecimal (4.0.1)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
|
|
@ -43,7 +42,7 @@ GEM
|
|||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.112.0)
|
||||
faraday (1.10.5)
|
||||
faraday (1.10.4)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
|
|
@ -68,20 +67,19 @@ GEM
|
|||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.4)
|
||||
faraday-retry (1.0.3)
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.234.0)
|
||||
CFPropertyList (>= 2.3, < 5.0.0)
|
||||
abbrev (~> 0.1)
|
||||
fastimage (2.4.0)
|
||||
fastlane (2.230.0)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
abbrev (~> 0.1.2)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.197)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
base64 (~> 0.2)
|
||||
benchmark (>= 0.1.0)
|
||||
bundler (>= 1.17.3, < 5.0.0)
|
||||
base64 (~> 0.2.0)
|
||||
bundler (>= 1.12.0, < 3.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
|
|
@ -92,11 +90,11 @@ GEM
|
|||
faraday-cookie_jar (~> 0.0.6)
|
||||
faraday_middleware (~> 1.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.1.0)
|
||||
fastlane-sirp (>= 1.0.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, <= 2.1.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.0.0)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
|
|
@ -105,11 +103,10 @@ GEM
|
|||
logger (>= 1.6, < 2.0)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
mutex_m (~> 0.3)
|
||||
mutex_m (~> 0.3.0)
|
||||
naturally (~> 2.2)
|
||||
nkf (~> 0.2)
|
||||
nkf (~> 0.2.0)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
ostruct (>= 0.1.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.5)
|
||||
|
|
@ -122,42 +119,41 @@ GEM
|
|||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.4.1)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.1.0)
|
||||
fastlane-sirp (1.0.0)
|
||||
sysrandom (~> 1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.102.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-core (0.18.0)
|
||||
google-apis-androidpublisher_v3 (0.54.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-core (0.11.3)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (~> 1.9)
|
||||
httpclient (>= 2.8.3, < 3.a)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
httpclient (>= 2.8.1, < 3.a)
|
||||
mini_mime (~> 1.0)
|
||||
mutex_m
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
google-apis-iamcredentials_v1 (0.27.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.17.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.63.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.9.0)
|
||||
rexml
|
||||
google-apis-iamcredentials_v1 (0.17.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.13.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-storage_v1 (0.31.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-cloud-core (1.8.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (2.1.1)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.6.0)
|
||||
google-cloud-storage (1.61.0)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.5.0)
|
||||
google-cloud-storage (1.47.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
google-apis-iamcredentials_v1 (~> 0.18)
|
||||
google-apis-storage_v1 (>= 0.42)
|
||||
google-apis-iamcredentials_v1 (~> 0.1)
|
||||
google-apis-storage_v1 (~> 0.31.0)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (~> 1.9)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (1.11.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.1)
|
||||
googleauth (1.8.1)
|
||||
faraday (>= 0.17.3, < 3.a)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
|
|
@ -168,41 +164,42 @@ GEM
|
|||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.19.9)
|
||||
jwt (2.10.3)
|
||||
json (2.18.0)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.21.1)
|
||||
multi_json (1.19.1)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
nkf (0.3.0)
|
||||
nkf (0.2.0)
|
||||
optparse (0.8.1)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
public_suffix (7.0.5)
|
||||
rake (13.4.2)
|
||||
public_suffix (7.0.0)
|
||||
rake (13.3.1)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.8.0)
|
||||
retriable (3.1.2)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.4.1)
|
||||
security (0.1.5)
|
||||
signet (0.22.0)
|
||||
signet (0.21.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 4.0)
|
||||
multi_json (~> 1.10)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
sysrandom (1.0.5)
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
|
|
|
|||
60
android/app/build.gradle
Normal file
60
android/app/build.gradle
Normal 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 = "../.."
|
||||
}
|
||||
|
|
@ -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 = "../.."
|
||||
}
|
||||
|
|
@ -13,8 +13,7 @@
|
|||
android:label="Frosty"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules">
|
||||
android:allowBackup="false">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
|
@ -47,6 +46,9 @@
|
|||
<data android:host="m.twitch.tv" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.EnableImpeller"
|
||||
android:value="false" />
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,6 @@
|
|||
package com.tommychow.frosty
|
||||
|
||||
import cl.puntito.simple_pip_mode.PipCallbackHelperActivityWrapper
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
||||
class MainActivity: PipCallbackHelperActivityWrapper() {
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
flutterEngine.plugins.add(CookieExtractorPlugin())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
|
|
|
|||
|
|
@ -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
18
android/build.gradle
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -17,14 +17,11 @@ default_platform(:android)
|
|||
|
||||
platform :android do
|
||||
desc "Deploy a new version to the Google Play"
|
||||
lane :deploy do |options|
|
||||
# Upload to the Play Store using the provided credentials and AAB from flutter build.
|
||||
# With dry_run:true (release dry-run workflow), validate_only authenticates and
|
||||
# validates the AAB against Google Play without committing the release.
|
||||
lane :deploy do
|
||||
# Upload to the play store ussing the provided credentials and AAB from flutter build.
|
||||
upload_to_play_store(
|
||||
json_key_data: Base64.decode64(ENV["PLAY_STORE_CREDENTIALS"]),
|
||||
aab: "../build/app/outputs/bundle/release/app-release.aab",
|
||||
validate_only: options[:dry_run] || false
|
||||
aab: "../build/app/outputs/bundle/release/app-release.aab"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
|
@ -1 +1 @@
|
|||
Frosty
|
||||
Frosty for Twitch
|
||||
|
|
@ -1,8 +1,3 @@
|
|||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
org.gradle.caching=true
|
||||
org.gradle.parallel=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
|
||||
|
|
|
|||
28
android/settings.gradle
Normal file
28
android/settings.gradle
Normal 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"
|
||||
|
|
@ -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
|
|
@ -1,28 +1,30 @@
|
|||
## v5.3.1
|
||||
## v5.0.0
|
||||
|
||||
October 12, 2026
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed the player getting stuck on a Source error after the screen turns off or the network switches — it now reloads automatically instead of waiting for a manual refresh.
|
||||
|
||||
---
|
||||
|
||||
## v5.3.0
|
||||
|
||||
October 12, 2026
|
||||
January 1, 2026
|
||||
|
||||
### New Features
|
||||
|
||||
- Added a Follow / Unfollow button under the player (between video and chat) for live and offline channels.
|
||||
- Open multiple chat tabs for different channels with drag-to-reorder. Tabs are saved between sessions by default (configurable in settings).
|
||||
- Resize the chat and video areas in landscape mode by dragging the divider.
|
||||
- A new section in the Following tab shows which of your followed channels are currently offline.
|
||||
- Change your chat username color from the chat details menu.
|
||||
- A new randomize button for the accent color theme in settings.
|
||||
- View emotes and stream thumbnails in full resolution with swipe-to-dismiss gestures.
|
||||
- [Android] Open Twitch channel links directly in Frosty instead of the browser or Twitch app (by @micahmo on GitHub).
|
||||
|
||||
---
|
||||
### Improvements
|
||||
|
||||
## v5.2.9
|
||||
|
||||
October 7, 2026
|
||||
- Swipe down to enter Picture-in-Picture mode, and press the PiP button again to exit.
|
||||
- Redesigned the video overlay and added stream details to the app bar in chat-only mode.
|
||||
- Networking and data handling have been completely rewritten to be faster and more reliable.
|
||||
- Improved formatting for chat replies, quick copy-to-input for messages, and a clearer countdown for chat delays.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed tag stream lists reshuffling when loading more pages by paginating with Twitch’s sorted tag directory (GQL) instead of scanning Helix.
|
||||
- Fixed follow/unfollow failing with an integrity check error while logged in by confirming via a short Twitch WebView when needed.
|
||||
- Fixed chat text input not unfocusing when tapping outside of it (by @zeykafx on GitHub).
|
||||
- Fixed missing badges and emotes in historical messages upon initial connection.
|
||||
- Fixed accidental chat scrolling when accessing system UI gestures in landscape mode.
|
||||
- Fixed chat reconnection issues when a chat delay was active.
|
||||
- Fixed duplicate message cases and various UI stability issues.
|
||||
|
||||
#### See all release notes at [https://github.com/tommyxchow/frosty/releases](https://github.com/tommyxchow/frosty/releases)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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`.
|
||||
|
|
@ -20,5 +20,7 @@
|
|||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>13.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
150
ios/Gemfile.lock
150
ios/Gemfile.lock
|
|
@ -1,49 +1,48 @@
|
|||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
CFPropertyList (3.0.7)
|
||||
base64
|
||||
nkf
|
||||
rexml
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1260.0)
|
||||
aws-sdk-core (3.252.0)
|
||||
aws-eventstream (1.3.2)
|
||||
aws-partitions (1.1082.0)
|
||||
aws-sdk-core (3.222.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.129.0)
|
||||
aws-sdk-core (~> 3, >= 3.248.0)
|
||||
aws-sdk-kms (1.99.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.225.1)
|
||||
aws-sdk-core (~> 3, >= 3.248.0)
|
||||
aws-sdk-s3 (1.183.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-sigv4 (1.11.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.3.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.2)
|
||||
base64 (0.2.0)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
csv (3.3.5)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.6.20240107)
|
||||
domain_name (0.5.20190701)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.112.0)
|
||||
faraday (1.10.5)
|
||||
excon (0.109.0)
|
||||
faraday (1.10.4)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
|
|
@ -55,36 +54,32 @@ GEM
|
|||
faraday-rack (~> 1.0)
|
||||
faraday-retry (~> 1.0)
|
||||
ruby2_keywords (>= 0.0.4)
|
||||
faraday-cookie_jar (0.0.8)
|
||||
faraday-cookie_jar (0.0.7)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (>= 1.0.0)
|
||||
http-cookie (~> 1.0.0)
|
||||
faraday-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.1)
|
||||
faraday-em_synchrony (1.0.0)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.2.0)
|
||||
faraday-multipart (1.1.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (1.0.2)
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.4)
|
||||
faraday-retry (1.0.3)
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.234.0)
|
||||
CFPropertyList (>= 2.3, < 5.0.0)
|
||||
abbrev (~> 0.1)
|
||||
fastimage (2.4.0)
|
||||
fastlane (2.227.0)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.197)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
base64 (~> 0.2)
|
||||
benchmark (>= 0.1.0)
|
||||
bundler (>= 1.17.3, < 5.0.0)
|
||||
bundler (>= 1.12.0, < 3.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
excon (>= 0.71.0, < 1.0.0)
|
||||
|
|
@ -92,24 +87,20 @@ GEM
|
|||
faraday-cookie_jar (~> 0.0.6)
|
||||
faraday_middleware (~> 1.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.1.0)
|
||||
fastlane-sirp (>= 1.0.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, <= 2.1.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.0.0)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.1.0, < 3)
|
||||
logger (>= 1.6, < 2.0)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
mutex_m (~> 0.3)
|
||||
naturally (~> 2.2)
|
||||
nkf (~> 0.2)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
ostruct (>= 0.1.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.5)
|
||||
|
|
@ -120,44 +111,43 @@ GEM
|
|||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.4.1)
|
||||
xcpretty (~> 0.4.0)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.1.0)
|
||||
fastlane-sirp (1.0.0)
|
||||
sysrandom (~> 1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.102.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-core (0.18.0)
|
||||
google-apis-androidpublisher_v3 (0.54.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-core (0.11.3)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (~> 1.9)
|
||||
httpclient (>= 2.8.3, < 3.a)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
httpclient (>= 2.8.1, < 3.a)
|
||||
mini_mime (~> 1.0)
|
||||
mutex_m
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
google-apis-iamcredentials_v1 (0.27.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.17.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.63.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.9.0)
|
||||
rexml
|
||||
google-apis-iamcredentials_v1 (0.17.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.13.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-storage_v1 (0.29.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-cloud-core (1.6.1)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (2.1.1)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.6.0)
|
||||
google-cloud-storage (1.61.0)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.3.1)
|
||||
google-cloud-storage (1.45.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
google-apis-iamcredentials_v1 (~> 0.18)
|
||||
google-apis-storage_v1 (>= 0.42)
|
||||
google-apis-iamcredentials_v1 (~> 0.1)
|
||||
google-apis-storage_v1 (~> 0.29.0)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (~> 1.9)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (1.11.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.1)
|
||||
googleauth (1.8.1)
|
||||
faraday (>= 0.17.3, < 3.a)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
|
|
@ -168,41 +158,42 @@ GEM
|
|||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.19.9)
|
||||
jwt (2.10.3)
|
||||
json (2.7.6)
|
||||
jwt (2.10.1)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.21.1)
|
||||
multi_json (1.15.0)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
nkf (0.3.0)
|
||||
optparse (0.8.1)
|
||||
naturally (2.2.1)
|
||||
nkf (0.2.0)
|
||||
optparse (0.6.0)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
public_suffix (7.0.5)
|
||||
rake (13.4.2)
|
||||
public_suffix (5.1.1)
|
||||
rake (13.2.1)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.8.0)
|
||||
rexml (3.4.4)
|
||||
retriable (3.1.2)
|
||||
rexml (3.4.2)
|
||||
rouge (3.28.0)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.4.1)
|
||||
security (0.1.5)
|
||||
signet (0.22.0)
|
||||
signet (0.18.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 4.0)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
multi_json (~> 1.10)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
sysrandom (1.0.5)
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
|
|
@ -212,6 +203,7 @@ GEM
|
|||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
uber (0.1.0)
|
||||
unf (0.2.0)
|
||||
unicode-display_width (2.6.0)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.27.0)
|
||||
|
|
|
|||
305
ios/Podfile.lock
305
ios/Podfile.lock
|
|
@ -1,27 +1,316 @@
|
|||
PODS:
|
||||
- advanced_in_app_review (0.0.1):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- flutter_secure_storage (6.0.0):
|
||||
- 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_native_splash (2.4.3):
|
||||
- Flutter
|
||||
- flutter_secure_storage_darwin (10.0.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- 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)
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- PromisesObjC (2.4.0)
|
||||
- PromisesSwift (2.4.0):
|
||||
- PromisesObjC (= 2.4.0)
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqflite_darwin (0.0.4):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- wakelock_plus (0.0.1):
|
||||
- Flutter
|
||||
- webview_flutter_wkwebview (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
|
||||
DEPENDENCIES:
|
||||
- advanced_in_app_review (from `.symlinks/plugins/advanced_in_app_review/ios`)
|
||||
- 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_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
|
||||
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- Firebase
|
||||
- FirebaseABTesting
|
||||
- FirebaseAnalytics
|
||||
- FirebaseCore
|
||||
- FirebaseCoreExtension
|
||||
- FirebaseCoreInternal
|
||||
- FirebaseCrashlytics
|
||||
- FirebaseInstallations
|
||||
- FirebasePerformance
|
||||
- FirebaseRemoteConfig
|
||||
- FirebaseRemoteConfigInterop
|
||||
- FirebaseSessions
|
||||
- FirebaseSharedSwift
|
||||
- GoogleAdsOnDeviceConversion
|
||||
- GoogleAppMeasurement
|
||||
- GoogleDataTransport
|
||||
- GoogleUtilities
|
||||
- nanopb
|
||||
- PromisesObjC
|
||||
- PromisesSwift
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
advanced_in_app_review:
|
||||
:path: ".symlinks/plugins/advanced_in_app_review/ios"
|
||||
app_links:
|
||||
:path: ".symlinks/plugins/app_links/ios"
|
||||
device_info_plus:
|
||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||
firebase_analytics:
|
||||
:path: ".symlinks/plugins/firebase_analytics/ios"
|
||||
firebase_core:
|
||||
:path: ".symlinks/plugins/firebase_core/ios"
|
||||
firebase_crashlytics:
|
||||
:path: ".symlinks/plugins/firebase_crashlytics/ios"
|
||||
firebase_performance:
|
||||
:path: ".symlinks/plugins/firebase_performance/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_secure_storage:
|
||||
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
||||
flutter_native_splash:
|
||||
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
||||
flutter_secure_storage_darwin:
|
||||
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
|
||||
in_app_review:
|
||||
:path: ".symlinks/plugins/in_app_review/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
sqflite_darwin:
|
||||
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
wakelock_plus:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
webview_flutter_wkwebview:
|
||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
advanced_in_app_review: daebe4c801525b756fe589de60fd327cb12f965c
|
||||
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
|
||||
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
||||
advanced_in_app_review: fe8fd50b8670cb3dbe67e4c26ca643f7aea8dbb8
|
||||
app_links: 6d01271b3907b0ee7325c5297c75d697c4226c4d
|
||||
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_darwin: 557817588b80e60213cbecb573c45c76b788018d
|
||||
GoogleAdsOnDeviceConversion: d68c69dd9581a0f5da02617b6f377e5be483970f
|
||||
GoogleAppMeasurement: 3bf40aff49a601af5da1c3345702fcb4991d35ee
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||
in_app_review: 436034b18594851a7328d7f1c2ed5ec235b79cfc
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -11,13 +11,10 @@
|
|||
3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E751BC99755AAC391DC0B29F /* Pods_Runner.framework */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
F1C00CE01A2B3C4D00E5F6A8 /* CookieExtractorPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
BE8095795EAFF58C08A43B48 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A68609F67C30CFEF267CD4D3 /* GoogleService-Info.plist */; };
|
||||
D4F5A60712340001ABCD1234 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
|
|
@ -40,7 +37,6 @@
|
|||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieExtractorPlugin.swift; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
95D7E205EE04D01F6996ECFC /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
|
|
@ -52,9 +48,7 @@
|
|||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
A68609F67C30CFEF267CD4D3 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = "<group>"; };
|
||||
CFF3F6A0DA5A835B219C2D3C /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
E751BC99755AAC391DC0B29F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
|
|
@ -62,7 +56,6 @@
|
|||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||
3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
|
@ -73,7 +66,6 @@
|
|||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
|
|
@ -112,9 +104,7 @@
|
|||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */,
|
||||
);
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -141,9 +131,6 @@
|
|||
|
||||
/* Begin PBXNativeTarget section */
|
||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||
packageProductDependencies = (
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
);
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
|
|
@ -170,9 +157,6 @@
|
|||
|
||||
/* Begin PBXProject section */
|
||||
97C146E61CF9000F007C117D /* Project object */ = {
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
||||
);
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastUpgradeCheck = 1510;
|
||||
|
|
@ -212,7 +196,6 @@
|
|||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||
BE8095795EAFF58C08A43B48 /* GoogleService-Info.plist in Resources */,
|
||||
D4F5A60712340001ABCD1234 /* PrivacyInfo.xcprivacy in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
@ -259,7 +242,7 @@
|
|||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "# Symbol upload only matters for builds that ship; Debug dSYMs are absent or empty and fail the tool.\nif [ \"$CONFIGURATION\" = \"Debug\" ]; then\n echo \"Skipping Crashlytics symbol upload for Debug build\"\n exit 0\nfi\n# Locate upload-symbols whether Firebase is integrated via CocoaPods or Swift Package Manager.\nBIN=\"$PODS_ROOT/FirebaseCrashlytics/upload-symbols\"\n[ -f \"$BIN\" ] || BIN=\"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/upload-symbols\"\n\"$BIN\" --flutter-project \"$PROJECT_DIR/firebase_app_id_file.json\"\n";
|
||||
shellScript = "\"$PODS_ROOT/FirebaseCrashlytics/upload-symbols\" --flutter-project \"$PROJECT_DIR/firebase_app_id_file.json\" ";
|
||||
};
|
||||
34CC65D58A1948B306772884 /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
|
|
@ -317,7 +300,6 @@
|
|||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
F1C00CE01A2B3C4D00E5F6A8 /* CookieExtractorPlugin.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
|
@ -385,7 +367,7 @@
|
|||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
|
|
@ -467,7 +449,7 @@
|
|||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
|
|
@ -516,7 +498,7 @@
|
|||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
|
|
@ -604,18 +586,6 @@
|
|||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -5,24 +5,6 @@
|
|||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Run Prepare Flutter Framework Script"
|
||||
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PreActions>
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -2,23 +2,12 @@ import Flutter
|
|||
import UIKit
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
guard let cookieExtractorRegistrar = engineBridge.pluginRegistry.registrar(
|
||||
forPlugin: "CookieExtractorPlugin"
|
||||
) else {
|
||||
assertionFailure("Failed to create CookieExtractorPlugin registrar")
|
||||
return
|
||||
}
|
||||
CookieExtractorPlugin.register(with: cookieExtractorRegistrar)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -52,32 +52,7 @@
|
|||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>FlutterSceneDelegate</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>flutter</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -17,9 +17,7 @@ default_platform(:ios)
|
|||
|
||||
platform :ios do
|
||||
desc 'Push a new release build to the App Store'
|
||||
lane :release do |options|
|
||||
dry_run = options[:dry_run] || false
|
||||
|
||||
lane :release do
|
||||
# Setup for CI. Uses a keychain to prevent the CI from hanging due to password prompts.
|
||||
setup_ci
|
||||
|
||||
|
|
@ -31,9 +29,8 @@ platform :ios do
|
|||
is_key_content_base64: true
|
||||
)
|
||||
|
||||
# Manage code signing (with match). readonly on a dry run so we only verify the
|
||||
# existing cert/profile resolve and never regenerate or mutate the certificates repo.
|
||||
sync_code_signing(readonly: dry_run)
|
||||
# Manage code signing (with match).
|
||||
sync_code_signing
|
||||
|
||||
# Automatic signing is used during development, but fastlane needs to use manual signing.
|
||||
update_code_signing_settings(
|
||||
|
|
@ -48,11 +45,6 @@ platform :ios do
|
|||
output_directory: '../build/ios/archive'
|
||||
)
|
||||
|
||||
# On a dry run, stop here: the signed archive above exercises every step that
|
||||
# actually fails (ASC key, match certs/profiles, signing, release build). The
|
||||
# App Store upload itself is the only remaining unverified step.
|
||||
next if dry_run
|
||||
|
||||
# Upload the app to the App Store.
|
||||
upload_to_app_store(
|
||||
submit_for_review: true,
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Frosty
|
||||
Frosty for Twitch
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
https://frostyapp.io/privacy
|
||||
https://www.privacypolicies.com/live/095b4264-31b1-4280-837e-5843abde8b16
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
|
@ -1 +1 @@
|
|||
Community emotes in chat
|
||||
See 7TV, BTTV, and FFZ emotes!
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import 'package:dio/dio.dart';
|
||||
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||
|
||||
/// Common type aliases to reduce repetition
|
||||
typedef JsonMap = Map<String, dynamic>;
|
||||
|
|
@ -44,13 +43,6 @@ class UnauthorizedException extends ApiException {
|
|||
: super(message, 401, DioExceptionType.badResponse);
|
||||
}
|
||||
|
||||
/// Twitch GQL rejected the request for a missing/invalid Client-Integrity token.
|
||||
class IntegrityCheckException extends ApiException {
|
||||
const IntegrityCheckException([
|
||||
String message = 'Twitch integrity check failed',
|
||||
]) : super(message);
|
||||
}
|
||||
|
||||
/// Base API client to eliminate code duplication across API services
|
||||
abstract class BaseApiClient {
|
||||
final Dio _dio;
|
||||
|
|
@ -165,40 +157,29 @@ abstract class BaseApiClient {
|
|||
|
||||
/// Converts DioException to appropriate ApiException subtype
|
||||
ApiException _handleError(DioException error) {
|
||||
final exception = switch (error.type) {
|
||||
DioExceptionType.connectionTimeout ||
|
||||
DioExceptionType.sendTimeout ||
|
||||
DioExceptionType.receiveTimeout ||
|
||||
DioExceptionType.transformTimeout =>
|
||||
TimeoutException(_getTimeoutMessage(error.type)),
|
||||
DioExceptionType.connectionError => NetworkException(
|
||||
'No internet connection. Please check your network.',
|
||||
),
|
||||
DioExceptionType.badResponse => _handleHttpError(error),
|
||||
DioExceptionType.cancel => ApiException('Request was cancelled'),
|
||||
DioExceptionType.badCertificate =>
|
||||
ApiException('Security certificate error'),
|
||||
DioExceptionType.unknown =>
|
||||
ApiException(error.message ?? 'An unexpected error occurred'),
|
||||
};
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return TimeoutException(_getTimeoutMessage(error.type));
|
||||
|
||||
// Record server errors and unexpected failures to Crashlytics.
|
||||
// Skip timeouts, network errors (normal offline conditions), 401s, and cancellations.
|
||||
if (exception is ServerException ||
|
||||
error.type == DioExceptionType.unknown ||
|
||||
error.type == DioExceptionType.badCertificate) {
|
||||
try {
|
||||
FirebaseCrashlytics.instance.recordError(
|
||||
exception,
|
||||
error.stackTrace,
|
||||
reason: '${error.requestOptions.method} ${error.requestOptions.uri}',
|
||||
case DioExceptionType.connectionError:
|
||||
return NetworkException(
|
||||
'No internet connection. Please check your network.',
|
||||
);
|
||||
} 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
|
||||
|
|
@ -210,8 +191,6 @@ abstract class BaseApiClient {
|
|||
return 'Upload timeout. Please try again.';
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return 'Server response timeout. Please try again.';
|
||||
case DioExceptionType.transformTimeout:
|
||||
return 'Response processing timeout. Please try again.';
|
||||
default:
|
||||
return 'Request timeout. Please try again.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:frosty/apis/firebase_performance_interceptor.dart';
|
||||
|
||||
/// Centralized Dio client configuration with interceptors and error handling
|
||||
class DioClient {
|
||||
|
|
@ -56,9 +55,6 @@ class DioClient {
|
|||
);
|
||||
}
|
||||
|
||||
// Trace HTTP requests as Firebase Performance metrics
|
||||
dio.interceptors.add(FirebasePerformanceInterceptor());
|
||||
|
||||
// Add any additional interceptors provided
|
||||
if (additionalInterceptors != null) {
|
||||
for (final interceptor in additionalInterceptors) {
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ class FFZApi extends BaseApiClient {
|
|||
);
|
||||
}
|
||||
|
||||
/// Returns a map of user IDs to a list of their FFZ badges.
|
||||
/// Returns a map of badges user IDs to a list of their FFZ badges.
|
||||
Future<Map<String, List<ChatBadge>>> getBadges() async {
|
||||
final data = await get<JsonMap>('/badges/ids');
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -145,29 +145,17 @@ class TwitchApi extends BaseApiClient {
|
|||
return data['access_token'] as String;
|
||||
}
|
||||
|
||||
/// Validates [token] with Twitch.
|
||||
///
|
||||
/// Returns token info (scopes + optional `user_id`) when valid, or `null`
|
||||
/// when invalid/expired. App-access tokens may omit `user_id` and return an
|
||||
/// empty scope list.
|
||||
Future<TwitchTokenInfo?> validateToken({required String token}) async {
|
||||
/// Returns a bool indicating the validity of the given token.
|
||||
Future<bool> validateToken({required String token}) async {
|
||||
try {
|
||||
final data = await get<JsonMap>(
|
||||
await get<JsonMap>(
|
||||
'$_oauthBaseUrl/validate',
|
||||
headers: {'Authorization': 'Bearer $token'},
|
||||
);
|
||||
final scopes = data['scopes'];
|
||||
final scopeList = scopes is List
|
||||
? scopes.map((scope) => scope.toString()).toList()
|
||||
: const <String>[];
|
||||
final userId = data['user_id']?.toString();
|
||||
return TwitchTokenInfo(
|
||||
scopes: scopeList,
|
||||
userId: userId == null || userId.isEmpty ? null : userId,
|
||||
);
|
||||
return true;
|
||||
} on UnauthorizedException {
|
||||
// 401 -> token is invalid/expired (propagated from interceptor for validate requests)
|
||||
return null;
|
||||
return false;
|
||||
} on ApiException catch (e) {
|
||||
// Network/timeout/server errors -> treat as indeterminate, not invalid
|
||||
debugPrint('Token validation indeterminate: $e');
|
||||
|
|
@ -175,23 +163,11 @@ class TwitchApi extends BaseApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
/// Returns a [StreamsTwitch] object that contains top streams and a cursor for further requests.
|
||||
///
|
||||
/// [language] is an optional ISO 639-1 code (Helix accepts only one).
|
||||
Future<StreamsTwitch> getTopStreams({
|
||||
String? cursor,
|
||||
String? language,
|
||||
int first = 100,
|
||||
}) async {
|
||||
final queryParams = <String, dynamic>{
|
||||
'first': first,
|
||||
if (cursor != null) 'after': cursor,
|
||||
if (language != null && language.isNotEmpty) 'language': language,
|
||||
};
|
||||
|
||||
/// Returns a [StreamsTwitch] object that contains the top 20 streams and a cursor for further requests.
|
||||
Future<StreamsTwitch> getTopStreams({String? cursor}) async {
|
||||
final data = await get<JsonMap>(
|
||||
'/streams',
|
||||
queryParameters: queryParams,
|
||||
queryParameters: cursor != null ? {'after': cursor} : null,
|
||||
);
|
||||
|
||||
return StreamsTwitch.fromJson(data);
|
||||
|
|
@ -202,7 +178,7 @@ class TwitchApi extends BaseApiClient {
|
|||
required String id,
|
||||
String? cursor,
|
||||
}) async {
|
||||
final queryParams = {'user_id': id, 'first': '100'};
|
||||
final queryParams = {'user_id': id};
|
||||
if (cursor != null) queryParams['after'] = cursor;
|
||||
|
||||
final data = await get<JsonMap>(
|
||||
|
|
@ -218,7 +194,7 @@ class TwitchApi extends BaseApiClient {
|
|||
required String userId,
|
||||
String? cursor,
|
||||
}) async {
|
||||
final queryParams = {'user_id': userId, 'first': '100'};
|
||||
final queryParams = {'user_id': userId, 'first': '20'};
|
||||
if (cursor != null) queryParams['after'] = cursor;
|
||||
|
||||
final data = await get<JsonMap>(
|
||||
|
|
@ -229,153 +205,19 @@ class TwitchApi extends BaseApiClient {
|
|||
return FollowedChannels.fromJson(data);
|
||||
}
|
||||
|
||||
/// Whether [userId] follows [broadcasterId], via Helix (read-only; create/delete
|
||||
/// follows were removed from the public API).
|
||||
Future<bool> isFollowingChannel({
|
||||
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.
|
||||
Future<StreamsTwitch> getStreamsUnderCategory({
|
||||
required String gameId,
|
||||
String? cursor,
|
||||
String? language,
|
||||
int first = 100,
|
||||
}) async {
|
||||
final queryParams = <String, dynamic>{
|
||||
'game_id': gameId,
|
||||
'first': first,
|
||||
if (cursor != null) 'after': cursor,
|
||||
if (language != null && language.isNotEmpty) 'language': language,
|
||||
};
|
||||
final queryParams = {'game_id': gameId};
|
||||
if (cursor != null) queryParams['after'] = cursor;
|
||||
|
||||
final data = await get<JsonMap>('/streams', queryParameters: queryParams);
|
||||
|
||||
return StreamsTwitch.fromJson(data);
|
||||
}
|
||||
|
||||
/// Fetches Helix directory pages until enough streams match [languages] and
|
||||
/// optional [freeformTag] (case-insensitive tag name), or the directory ends.
|
||||
///
|
||||
/// Helix is always viewer-desc and supports at most one `language` query
|
||||
/// param; tags are filtered client-side. Used as fallback when GQL directory
|
||||
/// pagination lacks a Client-Integrity token.
|
||||
///
|
||||
/// When a [freeformTag] filter yields no matches in a Helix window, this
|
||||
/// keeps scanning (up to [maxPages]) and still returns the Helix cursor so
|
||||
/// callers can continue — an empty match set alone is not end-of-list.
|
||||
Future<StreamsTwitch> getDirectoryStreams({
|
||||
List<String> languages = const [],
|
||||
String? gameId,
|
||||
String? freeformTag,
|
||||
String? cursor,
|
||||
int minCount = 50,
|
||||
int maxPages = 12,
|
||||
}) async {
|
||||
final wantedLangs = languages
|
||||
.map((l) => l.trim().toLowerCase())
|
||||
.where((l) => l.isNotEmpty)
|
||||
.toSet();
|
||||
final tagNeedle = freeformTag?.trim().toLowerCase();
|
||||
final hasTag = tagNeedle != null && tagNeedle.isNotEmpty;
|
||||
|
||||
bool matches(StreamTwitch stream) {
|
||||
if (wantedLangs.isNotEmpty &&
|
||||
!wantedLangs.contains(stream.language.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
if (hasTag) {
|
||||
final hit = stream.tags.any((t) => t.toLowerCase() == tagNeedle);
|
||||
if (!hit) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fast path: no tag filter and 0–1 languages → one Helix call.
|
||||
if (!hasTag && wantedLangs.length <= 1) {
|
||||
final lang = wantedLangs.isEmpty ? null : wantedLangs.first;
|
||||
return gameId == null
|
||||
? getTopStreams(cursor: cursor, language: lang)
|
||||
: getStreamsUnderCategory(
|
||||
gameId: gameId,
|
||||
cursor: cursor,
|
||||
language: lang,
|
||||
);
|
||||
}
|
||||
|
||||
// Sparse tags need a deeper Helix scan per request.
|
||||
final effectiveMin = hasTag ? minCount.clamp(1, 100) : minCount;
|
||||
final effectiveMax = hasTag ? (maxPages < 25 ? 25 : maxPages) : maxPages;
|
||||
|
||||
// When filtering by tag and exactly one language, still pass language to
|
||||
// Helix so each page is denser in matches.
|
||||
final helixLanguage =
|
||||
wantedLangs.length == 1 ? wantedLangs.first : null;
|
||||
|
||||
final matched = <StreamTwitch>[];
|
||||
String? pageCursor = cursor;
|
||||
String? nextCursor;
|
||||
var pages = 0;
|
||||
|
||||
while (matched.length < effectiveMin && pages < effectiveMax) {
|
||||
final page = gameId == null
|
||||
? await getTopStreams(cursor: pageCursor, language: helixLanguage)
|
||||
: await getStreamsUnderCategory(
|
||||
gameId: gameId,
|
||||
cursor: pageCursor,
|
||||
language: helixLanguage,
|
||||
);
|
||||
pages++;
|
||||
nextCursor = page.pagination['cursor'];
|
||||
for (final stream in page.data) {
|
||||
if (matches(stream)) matched.add(stream);
|
||||
}
|
||||
if (nextCursor == null ||
|
||||
nextCursor.isEmpty ||
|
||||
page.data.isEmpty) {
|
||||
nextCursor = null;
|
||||
break;
|
||||
}
|
||||
pageCursor = nextCursor;
|
||||
}
|
||||
|
||||
return StreamsTwitch(
|
||||
matched,
|
||||
nextCursor != null && nextCursor.isNotEmpty
|
||||
? {'cursor': nextCursor}
|
||||
: const {},
|
||||
);
|
||||
}
|
||||
|
||||
/// Deprecated name — use [getDirectoryStreams].
|
||||
Future<StreamsTwitch> getStreamsMatchingLanguages({
|
||||
required List<String> languages,
|
||||
String? gameId,
|
||||
String? cursor,
|
||||
int minCount = 40,
|
||||
int maxPages = 8,
|
||||
}) =>
|
||||
getDirectoryStreams(
|
||||
languages: languages,
|
||||
gameId: gameId,
|
||||
cursor: cursor,
|
||||
minCount: minCount,
|
||||
maxPages: maxPages,
|
||||
);
|
||||
|
||||
/// Returns a [StreamTwitch] object containing the stream info associated with the given [userLogin].
|
||||
Future<StreamTwitch> getStream({required String userLogin}) async {
|
||||
final data = await get<JsonMap>(
|
||||
|
|
@ -623,131 +465,4 @@ class TwitchApi extends BaseApiClient {
|
|||
|
||||
return data['messages'] as JsonList;
|
||||
}
|
||||
|
||||
/// Returns a list of broadcaster IDs for channels the authenticated user
|
||||
/// moderates, following pagination so moderators of more than one page of
|
||||
/// channels aren't silently truncated at the first 100.
|
||||
Future<List<String>> getModeratedChannels({
|
||||
required String id,
|
||||
String? cursor,
|
||||
}) async {
|
||||
try {
|
||||
// Default page size is 20; request the max to keep the request count low.
|
||||
final queryParams = {'user_id': id, 'first': '100'};
|
||||
if (cursor != null) queryParams['after'] = cursor;
|
||||
|
||||
final data = await get<JsonMap>(
|
||||
'/moderation/channels',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
final channels = data['data'] as JsonList;
|
||||
final result = channels
|
||||
.map((channel) => (channel as JsonMap)['broadcaster_id'] as String?)
|
||||
.whereType<String>()
|
||||
.toList();
|
||||
|
||||
final paginationCursor = data['pagination']['cursor'];
|
||||
if (paginationCursor != null && channels.isNotEmpty) {
|
||||
// Throttle between pages to stay under Twitch's rate limit, matching
|
||||
// getUserBlockedList above.
|
||||
await Future.delayed(const Duration(milliseconds: 150));
|
||||
result.addAll(
|
||||
await getModeratedChannels(id: id, cursor: paginationCursor),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} on ApiException catch (e) {
|
||||
// Backstop: callers should skip this without moderator scopes, but a
|
||||
// stale/partial token must not take down chat.
|
||||
debugPrint('Failed to get moderated channels: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes a specific chat message.
|
||||
Future<bool> deleteChatMessage({
|
||||
required String broadcasterId,
|
||||
required String moderatorId,
|
||||
required String messageId,
|
||||
}) async {
|
||||
// Let ApiException propagate so the caller can surface Twitch's reason
|
||||
// (e.g. you can't delete the broadcaster's or another moderator's message)
|
||||
// instead of a generic failure.
|
||||
await delete<dynamic>(
|
||||
'/moderation/chat',
|
||||
queryParameters: {
|
||||
'broadcaster_id': broadcasterId,
|
||||
'moderator_id': moderatorId,
|
||||
'message_id': messageId,
|
||||
},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Bans or times out a user from a channel.
|
||||
///
|
||||
/// The optional `duration` in seconds will timeout the user. If omitted, the user is banned.
|
||||
/// The optional `reason` will be displayed to the banned user and other moderators.
|
||||
Future<bool> banUser({
|
||||
required String broadcasterId,
|
||||
required String moderatorId,
|
||||
required String userIdToBan,
|
||||
int? duration,
|
||||
String? reason,
|
||||
}) async {
|
||||
final requestBody = {
|
||||
'data': {
|
||||
'user_id': userIdToBan,
|
||||
'duration': ?duration,
|
||||
'reason': ?reason,
|
||||
},
|
||||
};
|
||||
|
||||
// Let ApiException propagate so the caller can surface Twitch's reason
|
||||
// (e.g. the target is a moderator and can't be banned) instead of a
|
||||
// generic failure.
|
||||
final response = await post<JsonMap>(
|
||||
'/moderation/bans',
|
||||
data: requestBody,
|
||||
queryParameters: {
|
||||
'broadcaster_id': broadcasterId,
|
||||
'moderator_id': moderatorId,
|
||||
},
|
||||
);
|
||||
final data = response['data'] as JsonList;
|
||||
return data.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Removes a ban or timeout for a user in a channel.
|
||||
///
|
||||
/// A timeout is a time-limited ban, so this lifts both. Lets ApiException
|
||||
/// propagate (e.g. the user isn't currently banned) so the caller can show
|
||||
/// Twitch's reason.
|
||||
Future<bool> unbanUser({
|
||||
required String broadcasterId,
|
||||
required String moderatorId,
|
||||
required String userId,
|
||||
}) async {
|
||||
await delete<dynamic>(
|
||||
'/moderation/bans',
|
||||
queryParameters: {
|
||||
'broadcaster_id': broadcasterId,
|
||||
'moderator_id': moderatorId,
|
||||
'user_id': userId,
|
||||
},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a successful Twitch `/oauth2/validate` call.
|
||||
class TwitchTokenInfo {
|
||||
final List<String> scopes;
|
||||
|
||||
/// Present for user access tokens; omitted for app access tokens.
|
||||
final String? userId;
|
||||
|
||||
const TwitchTokenInfo({required this.scopes, this.userId});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
|
|||
import 'package:frosty/main.dart';
|
||||
import 'package:frosty/screens/onboarding/login_webview.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/utils/twitch_oauth_scopes.dart';
|
||||
import 'package:frosty/widgets/frosty_dialog.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
|
|
@ -15,67 +14,16 @@ class UnauthorizedInterceptor extends Interceptor {
|
|||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
// Check if this is a 401 Unauthorized error on a Twitch Helix/OAuth request
|
||||
if (err.response?.statusCode == 401 &&
|
||||
_isTwitchHelixOrOAuth(err.requestOptions.uri)) {
|
||||
// Check if this is a 401 Unauthorized error
|
||||
if (err.response?.statusCode == 401) {
|
||||
// For token validation requests, let the error propagate so validateToken can handle it
|
||||
if (err.requestOptions.uri.path.endsWith('/validate')) {
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
|
||||
final path = err.requestOptions.uri.path;
|
||||
|
||||
// Background moderated-channels fetches must never nag — especially for
|
||||
// core-only tokens after an app upgrade.
|
||||
if (isTwitchModeratedChannelsPath(path)) {
|
||||
handler.reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Moderator actions without scopes → opt-in Enable flow, not a generic
|
||||
// "missing permissions" re-login for core scopes.
|
||||
if (isTwitchModeratorActionPath(path) &&
|
||||
!_authStore.hasModeratorScopes) {
|
||||
_showAuthDialog(
|
||||
title: 'Enable moderator tools',
|
||||
message: enableModeratorToolsDialogMessage(),
|
||||
primaryLabel: 'Enable',
|
||||
onPrimary: () => openEnableModeratorTools(_authStore),
|
||||
);
|
||||
handler.reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Core-only messaging: if core scopes are present, prefer "Session expired"
|
||||
// over "Missing permissions" (401 ≠ always missing scope).
|
||||
final isLoggedIn = _authStore.isLoggedIn;
|
||||
final missingCore = missingTwitchScopes(
|
||||
granted: _authStore.grantedScopes,
|
||||
);
|
||||
_showAuthDialog(
|
||||
title: isLoggedIn && missingCore.isNotEmpty
|
||||
? 'Missing permissions'
|
||||
: 'Session expired',
|
||||
message: unauthorizedDialogMessage(
|
||||
isLoggedIn: isLoggedIn,
|
||||
grantedScopes: _authStore.grantedScopes,
|
||||
),
|
||||
primaryLabel: 'Log in',
|
||||
onPrimary: () {
|
||||
final context = navigatorKey.currentContext;
|
||||
if (context == null) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => Provider<AuthStore>.value(
|
||||
value: _authStore,
|
||||
child: const LoginWebView(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
handler.reject(err);
|
||||
// For other requests, show login dialog and don't propagate
|
||||
_showLoginDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -83,20 +31,25 @@ class UnauthorizedInterceptor extends Interceptor {
|
|||
handler.next(err);
|
||||
}
|
||||
|
||||
void _showAuthDialog({
|
||||
required String title,
|
||||
required String message,
|
||||
required String primaryLabel,
|
||||
required VoidCallback onPrimary,
|
||||
}) {
|
||||
void _showLoginDialog() {
|
||||
// Use the global navigator key to show dialog without needing BuildContext
|
||||
final context = navigatorKey.currentContext;
|
||||
if (context == null) return;
|
||||
|
||||
// Use a flag to prevent multiple dialogs
|
||||
if (_isDialogShowing) return;
|
||||
_isDialogShowing = true;
|
||||
|
||||
// Determine if user is logged in but missing scopes vs completely logged out
|
||||
final isLoggedIn = _authStore.isLoggedIn;
|
||||
final title = isLoggedIn ? 'Missing permissions' : 'Session expired';
|
||||
final message = isLoggedIn
|
||||
? 'Your session is missing permissions. Please log in again to continue.'
|
||||
: 'Your session has expired. Please log in again to continue.';
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
barrierDismissible: false, // User must choose an action
|
||||
builder: (BuildContext dialogContext) {
|
||||
return FrostyDialog(
|
||||
title: title,
|
||||
|
|
@ -105,39 +58,34 @@ class UnauthorizedInterceptor extends Interceptor {
|
|||
TextButton(
|
||||
onPressed: () {
|
||||
_isDialogShowing = false;
|
||||
Navigator.of(dialogContext).pop();
|
||||
Navigator.of(dialogContext).pop(); // Close dialog
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
_isDialogShowing = false;
|
||||
Navigator.of(dialogContext).pop();
|
||||
onPrimary();
|
||||
Navigator.of(dialogContext).pop(); // Close dialog
|
||||
// Navigate to login WebView
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => Provider<AuthStore>.value(
|
||||
value: _authStore,
|
||||
child: const LoginWebView(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text(primaryLabel),
|
||||
child: Text('Log in'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
).then((_) {
|
||||
// Reset flag when dialog is dismissed
|
||||
_isDialogShowing = false;
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -3,18 +3,6 @@ import 'package:flutter/material.dart';
|
|||
/// Twitch API client ID.
|
||||
const clientId = String.fromEnvironment('CLIENT_ID');
|
||||
|
||||
/// Twitch GQL client ID (web player, used for playback access tokens).
|
||||
const twitchGqlClientId = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
|
||||
|
||||
/// SharedPreferences key for the per-channel last-selected stream quality.
|
||||
/// A saved variant that doesn't exist on the target channel (e.g. 1080p on a
|
||||
/// source-only channel) falls back to Auto via the quality-list lookup.
|
||||
String lastStreamQualityKey(String userLogin) =>
|
||||
'last_stream_quality_$userLogin';
|
||||
|
||||
/// Legacy global key. Cleared on startup during SharedPreferences init.
|
||||
const kLegacyLastStreamQualityKey = 'last_stream_quality';
|
||||
|
||||
/// Twitch API client secret.
|
||||
const secret = String.fromEnvironment('SECRET');
|
||||
|
||||
|
|
@ -54,9 +42,6 @@ final regexLink = RegExp(
|
|||
caseSensitive: false,
|
||||
);
|
||||
|
||||
/// Standard corner radius for card thumbnails and box art.
|
||||
const kCardBorderRadius = BorderRadius.all(Radius.circular(8));
|
||||
|
||||
/// The default badge width and height.
|
||||
const defaultBadgeSize = 18.0;
|
||||
|
||||
|
|
|
|||
134
lib/main.dart
134
lib/main.dart
|
|
@ -1,30 +1,22 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:advanced_in_app_review/advanced_in_app_review.dart';
|
||||
import 'package:app_links/app_links.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||
import 'package:firebase_performance/firebase_performance.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:frosty/apis/base_api_client.dart' show ApiException;
|
||||
import 'package:frosty/apis/bttv_api.dart';
|
||||
import 'package:frosty/apis/dio_client.dart';
|
||||
import 'package:frosty/apis/ffz_api.dart';
|
||||
import 'package:frosty/apis/seventv_api.dart';
|
||||
import 'package:frosty/apis/twitch_api.dart';
|
||||
import 'package:frosty/apis/twitch_auth_interceptor.dart';
|
||||
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||
import 'package:frosty/apis/unauthorized_interceptor.dart';
|
||||
import 'package:frosty/cache_manager.dart';
|
||||
import 'package:frosty/constants.dart';
|
||||
import 'package:frosty/firebase_options.dart';
|
||||
import 'package:frosty/screens/channel/channel.dart';
|
||||
import 'package:frosty/screens/home/home.dart';
|
||||
|
|
@ -39,25 +31,6 @@ import 'package:mobx/mobx.dart';
|
|||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
/// Errors that are expected on a mobile client — transient network/I/O, plugin
|
||||
/// teardown races, and image fetch failures — and therefore shouldn't be
|
||||
/// reported to Crashlytics as fatal crashes (which would tank the crash-free
|
||||
/// metric and bucket unrelated errors together under the global handlers).
|
||||
///
|
||||
/// This is a last-resort net. The layers that own these errors already record
|
||||
/// them non-fatally with precise reasons (e.g. base_api_client, the IRC
|
||||
/// websocket handlers); this only catches what escapes that local handling.
|
||||
bool _isNonFatalError(Object error) {
|
||||
return error is SocketException ||
|
||||
error is HttpException ||
|
||||
error is WebSocketChannelException ||
|
||||
error is ApiException ||
|
||||
error is MissingPluginException ||
|
||||
error is NetworkImageLoadException ||
|
||||
(error is DioException && error.type != DioExceptionType.badResponse);
|
||||
}
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
|
@ -66,60 +39,26 @@ void main() async {
|
|||
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||
|
||||
// Route uncaught framework errors to Crashlytics. Non-crashing UI errors
|
||||
// (layout overflows, image-load failures) and other expected transient
|
||||
// failures are recorded as non-fatal so they don't inflate the crash count.
|
||||
FlutterError.onError = (details) {
|
||||
// Run the cheap type checks first; only fall back to the diagnostics string
|
||||
// (RenderFlex overflow has no typed signal) when those miss.
|
||||
final isNonFatal =
|
||||
_isNonFatalError(details.exception) ||
|
||||
details.exceptionAsString().contains('overflowed');
|
||||
if (isNonFatal) {
|
||||
FirebaseCrashlytics.instance.recordFlutterError(details);
|
||||
} else {
|
||||
FirebaseCrashlytics.instance.recordFlutterFatalError(details);
|
||||
}
|
||||
};
|
||||
// Pass all uncaught "fatal" errors from the framework to Crashlytics
|
||||
FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;
|
||||
|
||||
// Pass uncaught asynchronous errors to Crashlytics. Expected transient errors
|
||||
// are recorded non-fatally; everything else stays fatal. The reason gives the
|
||||
// grouping engine a real signal so distinct errors don't collapse into one
|
||||
// catch-all issue under this handler frame.
|
||||
// Pass all uncaught asynchronous errors that aren't handled by the Flutter framework to Crashlytics
|
||||
PlatformDispatcher.instance.onError = (error, stack) {
|
||||
FirebaseCrashlytics.instance.recordError(
|
||||
error,
|
||||
stack,
|
||||
reason: error.runtimeType.toString(),
|
||||
fatal: !_isNonFatalError(error),
|
||||
);
|
||||
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
|
||||
return true;
|
||||
};
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// One-time migration: the quality preference used to be stored under a
|
||||
// single global key, which meant a saved 1080p60 from one channel could
|
||||
// pin another channel to a variant it doesn't serve. Remove the legacy
|
||||
// key; new writes go under per-channel keys.
|
||||
await prefs.remove(kLegacyLastStreamQualityKey);
|
||||
|
||||
final firstRun = prefs.getBool('first_run') ?? true;
|
||||
|
||||
// Workaround for clearing stored tokens on uninstall.
|
||||
// If first time running app, will clear all tokens in the secure storage.
|
||||
// Run non-blocking to avoid ANR on slow Android devices (especially Android 14).
|
||||
if (firstRun) {
|
||||
debugPrint('Clearing secure storage...');
|
||||
const storage = FlutterSecureStorage();
|
||||
|
||||
// Don't block - run in background after app starts.
|
||||
// This is safe because first run means no active session depends on this data.
|
||||
unawaited(
|
||||
storage.deleteAll().catchError((e) {
|
||||
debugPrint('Error clearing secure storage: $e');
|
||||
}),
|
||||
);
|
||||
await storage.deleteAll();
|
||||
}
|
||||
|
||||
await initUtils();
|
||||
|
|
@ -131,19 +70,6 @@ void main() async {
|
|||
// Initialize a settings store from the settings JSON string.
|
||||
final settingsStore = SettingsStore.fromJson(jsonDecode(userSettings));
|
||||
|
||||
// Disable Firebase collection in debug builds to avoid polluting production data.
|
||||
// In release builds, respect the user's preference.
|
||||
if (kDebugMode) {
|
||||
FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(false);
|
||||
FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(false);
|
||||
FirebasePerformance.instance.setPerformanceCollectionEnabled(false);
|
||||
} else {
|
||||
final shareEnabled = settingsStore.shareCrashLogsAndAnalytics;
|
||||
FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(shareEnabled);
|
||||
FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(shareEnabled);
|
||||
FirebasePerformance.instance.setPerformanceCollectionEnabled(shareEnabled);
|
||||
}
|
||||
|
||||
// Create a MobX reaction that will save the settings on disk every time they are changed.
|
||||
autorun((_) => prefs.setString('settings', jsonEncode(settingsStore)));
|
||||
|
||||
|
|
@ -156,7 +82,6 @@ void main() async {
|
|||
final bttvApiService = BTTVApi(dioClient);
|
||||
final ffzApiService = FFZApi(dioClient);
|
||||
final sevenTVApiService = SevenTVApi(dioClient);
|
||||
final twitchGqlApiService = TwitchGqlApi(dioClient);
|
||||
|
||||
// Create global assets store (shared cache for global emotes/badges)
|
||||
final globalAssetsStore = GlobalAssetsStore(
|
||||
|
|
@ -167,10 +92,7 @@ void main() async {
|
|||
);
|
||||
|
||||
// Create and initialize the authentication store
|
||||
final authStore = AuthStore(
|
||||
twitchApi: twitchApiService,
|
||||
twitchGqlApi: twitchGqlApiService,
|
||||
);
|
||||
final authStore = AuthStore(twitchApi: twitchApiService);
|
||||
|
||||
// Add the auth interceptor to the Dio client after AuthStore creation
|
||||
dioClient.interceptors.add(TwitchAuthInterceptor(authStore));
|
||||
|
|
@ -179,10 +101,6 @@ void main() async {
|
|||
dioClient.interceptors.add(UnauthorizedInterceptor(authStore));
|
||||
|
||||
await authStore.init();
|
||||
FirebaseCrashlytics.instance.setCustomKey('is_logged_in', authStore.isLoggedIn);
|
||||
if (authStore.isLoggedIn && authStore.user.details != null) {
|
||||
FirebaseCrashlytics.instance.setUserIdentifier(authStore.user.details!.id);
|
||||
}
|
||||
|
||||
runApp(
|
||||
MultiProvider(
|
||||
|
|
@ -193,7 +111,6 @@ void main() async {
|
|||
Provider<BTTVApi>.value(value: bttvApiService),
|
||||
Provider<FFZApi>.value(value: ffzApiService),
|
||||
Provider<SevenTVApi>.value(value: sevenTVApiService),
|
||||
Provider<TwitchGqlApi>.value(value: twitchGqlApiService),
|
||||
Provider<GlobalAssetsStore>.value(value: globalAssetsStore),
|
||||
],
|
||||
child: MyApp(firstRun: firstRun),
|
||||
|
|
@ -240,22 +157,20 @@ class _MyAppState extends State<MyApp> {
|
|||
colorSchemeSeed: Color(settingsStore.accentColor),
|
||||
);
|
||||
|
||||
return MaterialApp(
|
||||
title: 'Frosty',
|
||||
theme: themes.light,
|
||||
darkTheme: themes.dark,
|
||||
themeMode: settingsStore.themeType == ThemeType.system
|
||||
? ThemeMode.system
|
||||
: settingsStore.themeType == ThemeType.light
|
||||
? ThemeMode.light
|
||||
: ThemeMode.dark,
|
||||
navigatorObservers: [
|
||||
FirebaseAnalyticsObserver(
|
||||
analytics: FirebaseAnalytics.instance,
|
||||
),
|
||||
],
|
||||
home: widget.firstRun ? const OnboardingIntro() : const Home(),
|
||||
navigatorKey: navigatorKey,
|
||||
return Provider<FrostyThemes>(
|
||||
create: (_) => themes,
|
||||
child: MaterialApp(
|
||||
title: 'Frosty',
|
||||
theme: themes.light,
|
||||
darkTheme: themes.dark,
|
||||
themeMode: settingsStore.themeType == ThemeType.system
|
||||
? ThemeMode.system
|
||||
: settingsStore.themeType == ThemeType.light
|
||||
? ThemeMode.light
|
||||
: ThemeMode.dark,
|
||||
home: widget.firstRun ? const OnboardingIntro() : const Home(),
|
||||
navigatorKey: navigatorKey,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -268,12 +183,8 @@ class _MyAppState extends State<MyApp> {
|
|||
handleDeepLink(uri);
|
||||
});
|
||||
|
||||
// Handle the initial link if app was opened from a link.
|
||||
// Add timeout to prevent indefinite blocking on certain Android lifecycle states.
|
||||
final initialLink = await _appLinks.getInitialLink().timeout(
|
||||
const Duration(seconds: 3),
|
||||
onTimeout: () => null,
|
||||
);
|
||||
// Handle the initial link if app was opened from a link
|
||||
final initialLink = await _appLinks.getInitialLink();
|
||||
if (initialLink != null) {
|
||||
handleDeepLink(initialLink);
|
||||
}
|
||||
|
|
@ -308,7 +219,6 @@ class _MyAppState extends State<MyApp> {
|
|||
final user = await twitchApi.getUser(userLogin: channelName);
|
||||
|
||||
final route = MaterialPageRoute(
|
||||
settings: const RouteSettings(name: VideoChat.routeName),
|
||||
builder: (context) => VideoChat(
|
||||
userId: user.id,
|
||||
userName: user.displayName,
|
||||
|
|
|
|||
|
|
@ -56,11 +56,6 @@ class IRCMessage {
|
|||
this.actionLabel,
|
||||
});
|
||||
|
||||
/// The channel this message actually originated from. In Twitch shared
|
||||
/// chat, `source-room-id` names the participant channel a message was
|
||||
/// sent in; otherwise it's just this connection's own room.
|
||||
String? get sourceChannelId => tags['source-room-id'] ?? tags['room-id'];
|
||||
|
||||
/// Applies the given CLEARCHAT message to the provided messages and buffer.
|
||||
static void clearChat({
|
||||
required List<IRCMessage> messages,
|
||||
|
|
@ -181,9 +176,7 @@ class IRCMessage {
|
|||
size: badgeSize,
|
||||
color:
|
||||
Theme.of(context).iconTheme.color?.withValues(alpha: 0.5) ??
|
||||
Theme.of(context).colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
Colors.grey.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -191,6 +184,7 @@ class IRCMessage {
|
|||
span.add(const TextSpan(text: ' '));
|
||||
}
|
||||
|
||||
final sourceChannelId = tags['source-room-id'] ?? tags['room-id'];
|
||||
final sourceChannelUser = channelIdToUserTwitch?[sourceChannelId];
|
||||
if (sourceChannelUser != null) {
|
||||
final isCurrentChannel = sourceChannelId == currentChannelId;
|
||||
|
|
@ -448,9 +442,8 @@ class IRCMessage {
|
|||
bool launchExternal,
|
||||
TextStyle? textStyle,
|
||||
void Function(String)? onTapPingedUser,
|
||||
void Function()? onTapDeletedMessage, {
|
||||
Color? linkColor,
|
||||
}) {
|
||||
void Function()? onTapDeletedMessage,
|
||||
) {
|
||||
if (!showMessage) {
|
||||
span.add(
|
||||
TextSpan(
|
||||
|
|
@ -500,7 +493,6 @@ class IRCMessage {
|
|||
emoteScale,
|
||||
textStyle,
|
||||
launchExternal,
|
||||
linkColor: linkColor,
|
||||
);
|
||||
} else {
|
||||
localSpan.add(
|
||||
|
|
@ -532,7 +524,6 @@ class IRCMessage {
|
|||
_createTextSpan(
|
||||
text: word,
|
||||
style: textStyle,
|
||||
linkColor: linkColor,
|
||||
launchExternal: launchExternal,
|
||||
onTapPingedUser: onTapPingedUser,
|
||||
),
|
||||
|
|
@ -559,9 +550,8 @@ class IRCMessage {
|
|||
double emoteSize,
|
||||
double emoteScale,
|
||||
TextStyle? textStyle,
|
||||
bool launchExternal, {
|
||||
Color? linkColor,
|
||||
}) {
|
||||
bool launchExternal,
|
||||
) {
|
||||
final emoteStack = <Emote>[];
|
||||
var index = startIndex;
|
||||
|
||||
|
|
@ -641,7 +631,6 @@ class IRCMessage {
|
|||
_createTextSpan(
|
||||
text: words[index],
|
||||
style: textStyle,
|
||||
linkColor: linkColor,
|
||||
launchExternal: launchExternal,
|
||||
),
|
||||
);
|
||||
|
|
@ -667,31 +656,16 @@ class IRCMessage {
|
|||
bool showMessage = true,
|
||||
Map<String, UserTwitch>? channelIdToUserTwitch,
|
||||
TimestampType timestamp = TimestampType.disabled,
|
||||
bool forceTimestamp = false,
|
||||
bool showHistoricalTimestamps = false,
|
||||
String? currentChannelId,
|
||||
}) {
|
||||
final emoteToObject = assetsStore.emoteToObject;
|
||||
final badgeSize = defaultBadgeSize * badgeScale;
|
||||
final emoteSize = defaultEmoteSize * emoteScale;
|
||||
final linkColor = Theme.of(context).colorScheme.primary;
|
||||
|
||||
// The span list that will be used to render the chat message
|
||||
final span = <InlineSpan>[];
|
||||
|
||||
// When the user has timestamps disabled globally, fall back to the device's
|
||||
// locale preference for historical messages (opt-in via setting) and for
|
||||
// forced contexts like previews.
|
||||
final isHistorical = tags['historical'] == '1';
|
||||
final effectiveTimestamp =
|
||||
(timestamp == TimestampType.disabled &&
|
||||
((isHistorical && showHistoricalTimestamps) || forceTimestamp))
|
||||
? (MediaQuery.alwaysUse24HourFormatOf(context)
|
||||
? TimestampType.twentyFour
|
||||
: TimestampType.twelve)
|
||||
: timestamp;
|
||||
|
||||
_addTimestamp(span, style, effectiveTimestamp);
|
||||
_addTimestamp(span, style, timestamp);
|
||||
_addHistoricalAndChannelBadges(
|
||||
context,
|
||||
span,
|
||||
|
|
@ -700,6 +674,7 @@ class IRCMessage {
|
|||
currentChannelId,
|
||||
);
|
||||
|
||||
final isHistorical = tags['historical'] == '1';
|
||||
_addUserBadges(
|
||||
context,
|
||||
span,
|
||||
|
|
@ -727,7 +702,6 @@ class IRCMessage {
|
|||
textStyle,
|
||||
onTapPingedUser,
|
||||
onTapDeletedMessage,
|
||||
linkColor: linkColor,
|
||||
);
|
||||
|
||||
return span;
|
||||
|
|
@ -836,7 +810,6 @@ class IRCMessage {
|
|||
required String text,
|
||||
required bool launchExternal,
|
||||
TextStyle? style,
|
||||
Color? linkColor,
|
||||
Function(String)? onTapPingedUser,
|
||||
}) {
|
||||
if (text.startsWith('@')) {
|
||||
|
|
@ -854,9 +827,9 @@ class IRCMessage {
|
|||
return TextSpan(
|
||||
text: text,
|
||||
style: style?.copyWith(
|
||||
color: linkColor,
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: linkColor,
|
||||
decorationColor: Colors.blue,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () => launchUrl(
|
||||
|
|
@ -978,31 +951,22 @@ class IRCMessage {
|
|||
// Obtain the index to break off the tags.
|
||||
final tagAndIrcMessageDivider = whole.indexOf(' ');
|
||||
|
||||
// Without a separating space there are no tags to slice and no message body
|
||||
// to parse — bail out rather than throw on the substring/index calls below.
|
||||
if (tagAndIrcMessageDivider == -1) {
|
||||
return IRCMessage(raw: whole, command: Command.none, tags: const {});
|
||||
}
|
||||
// Get the tags substring and escape characters.
|
||||
// IRC messages escape spaces with \s.
|
||||
final tags = whole
|
||||
.substring(1, tagAndIrcMessageDivider)
|
||||
.replaceAll('\\s', ' ');
|
||||
|
||||
// Parse the IRCv3 tags into a map. The tags section is everything between
|
||||
// the leading '@' and the first space. We split on the *raw* string: a
|
||||
// literal ';' is always a separator because escaped semicolons inside a
|
||||
// value are encoded as `\:` (unescaped per-value below).
|
||||
// Next, parse and map the tags.
|
||||
final mappedTags = <String, String>{};
|
||||
final rawTags = whole.substring(1, tagAndIrcMessageDivider);
|
||||
|
||||
for (final tag in rawTags.split(';')) {
|
||||
// Split on the first '=' only — IRCv3 tag values may contain '='.
|
||||
final equalsIndex = tag.indexOf('=');
|
||||
// Loop through each tag and store their key value pairs into the map.
|
||||
for (final tag in tags.split(';')) {
|
||||
// Skip if the tag has no value.
|
||||
if (tag.endsWith('=')) continue;
|
||||
|
||||
// Skip valueless tags. Per the IRCv3 spec a missing value (bare `key`)
|
||||
// and an empty value (`key=`) are equivalent — live Twitch sends the
|
||||
// latter, the recent-messages API the former.
|
||||
if (equalsIndex == -1 || equalsIndex == tag.length - 1) continue;
|
||||
|
||||
mappedTags[tag.substring(0, equalsIndex)] = _unescapeTagValue(
|
||||
tag.substring(equalsIndex + 1),
|
||||
);
|
||||
final tagSplit = tag.split('=');
|
||||
mappedTags[tagSplit[0]] = tagSplit[1];
|
||||
}
|
||||
|
||||
// Now we'll parse the message itself.
|
||||
|
|
@ -1016,29 +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
|
||||
final splitMessage = ircMessage.split(' ');
|
||||
|
||||
// A well-formed message is at least `<prefix> <COMMAND>`. A malformed or
|
||||
// truncated line (e.g. from the recent-messages history API) can split into
|
||||
// a single token, which would otherwise throw a RangeError on the
|
||||
// splitMessage[0]/[1] accesses below.
|
||||
if (splitMessage.length < 2) {
|
||||
return IRCMessage(raw: whole, command: Command.none, tags: mappedTags);
|
||||
}
|
||||
|
||||
// If the username exists, set it.
|
||||
// tmi.twitch.tv means the message was sent by Twitch rather than a user, so it is irrelevant.
|
||||
final prefix = splitMessage[0];
|
||||
final String? user;
|
||||
if (prefix == _twitchIrcServer) {
|
||||
user = mappedTags['login'];
|
||||
} else {
|
||||
// Normal prefixes are `nick!user@host`. Fall back to the login tag (or
|
||||
// null) if the expected `!` is absent so an unexpected prefix shape can
|
||||
// never throw a RangeError and take down the whole message.
|
||||
final bangIndex = prefix.indexOf('!');
|
||||
user = bangIndex == -1
|
||||
? mappedTags['login']
|
||||
: prefix.substring(0, bangIndex);
|
||||
}
|
||||
// tmi.twitch.tv means the message was sent by Twitch rather than a user, so will be irrelevant.
|
||||
final String? user = splitMessage[0] == _twitchIrcServer
|
||||
? mappedTags['login']
|
||||
: splitMessage[0].substring(0, splitMessage[0].indexOf('!'));
|
||||
|
||||
// If there is an associated message, set it.
|
||||
String? message;
|
||||
|
|
@ -1069,9 +1015,9 @@ class IRCMessage {
|
|||
indexBetweenIdAndPositions,
|
||||
);
|
||||
|
||||
// Parse the range to extract the associated word.
|
||||
// If there is more than one index, use the first.
|
||||
// Otherwise, use the provided index.
|
||||
// Parse the range in order to extract the associated word.
|
||||
// If there are more than one indices, use the first one.
|
||||
// Else, use the one provided indices.
|
||||
final String range;
|
||||
if (emoteIdAndPosition.contains(',')) {
|
||||
range = emoteIdAndPosition.substring(
|
||||
|
|
@ -1199,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({
|
||||
required String message,
|
||||
VoidCallback? actionCallback,
|
||||
String? actionLabel,
|
||||
}) => IRCMessage(
|
||||
raw: '',
|
||||
tags: {
|
||||
'tmi-sent-ts': '${DateTime.now().millisecondsSinceEpoch}',
|
||||
},
|
||||
tags: {},
|
||||
command: Command.notice,
|
||||
message: message,
|
||||
actionCallback: actionCallback,
|
||||
|
|
@ -1304,9 +1204,8 @@ class USERSTATE {
|
|||
raw: ircMessage.raw,
|
||||
color: ircMessage.tags['color'] ?? color,
|
||||
displayName: ircMessage.tags['display-name'] ?? displayName,
|
||||
// Missing tags must be false — the old `!= '0'` default treated absence as mod.
|
||||
mod: ircMessage.tags['mod'] == '1',
|
||||
subscriber: ircMessage.tags['subscriber'] == '1',
|
||||
mod: ircMessage.tags['mod'] == '0' ? false : true,
|
||||
subscriber: ircMessage.tags['subscriber'] == '0' ? false : true,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -15,14 +15,6 @@ class StreamTwitch {
|
|||
final String startedAt;
|
||||
final String thumbnailUrl;
|
||||
|
||||
/// ISO 639-1 broadcast language from Helix (`de`, `en`, …). Empty when unknown.
|
||||
@JsonKey(defaultValue: '')
|
||||
final String language;
|
||||
|
||||
/// Freeform tags from Helix (e.g. `Deutsch`, `English`).
|
||||
@JsonKey(defaultValue: <String>[])
|
||||
final List<String> tags;
|
||||
|
||||
const StreamTwitch(
|
||||
this.userId,
|
||||
this.userLogin,
|
||||
|
|
@ -32,10 +24,8 @@ class StreamTwitch {
|
|||
this.title,
|
||||
this.viewerCount,
|
||||
this.startedAt,
|
||||
this.thumbnailUrl, [
|
||||
this.language = '',
|
||||
this.tags = const [],
|
||||
]);
|
||||
this.thumbnailUrl,
|
||||
);
|
||||
|
||||
factory StreamTwitch.fromJson(Map<String, dynamic> json) =>
|
||||
_$StreamTwitchFromJson(json);
|
||||
|
|
@ -48,20 +38,6 @@ class StreamsTwitch {
|
|||
|
||||
const StreamsTwitch(this.data, this.pagination);
|
||||
|
||||
factory StreamsTwitch.fromJson(Map<String, dynamic> json) {
|
||||
final rawPagination = json['pagination'];
|
||||
final pagination = <String, String>{};
|
||||
if (rawPagination is Map) {
|
||||
final cursor = rawPagination['cursor'];
|
||||
if (cursor is String && cursor.isNotEmpty) {
|
||||
pagination['cursor'] = cursor;
|
||||
}
|
||||
}
|
||||
return StreamsTwitch(
|
||||
(json['data'] as List<dynamic>)
|
||||
.map((e) => StreamTwitch.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
pagination,
|
||||
);
|
||||
}
|
||||
factory StreamsTwitch.fromJson(Map<String, dynamic> json) =>
|
||||
_$StreamsTwitchFromJson(json);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@ StreamTwitch _$StreamTwitchFromJson(Map<String, dynamic> json) => StreamTwitch(
|
|||
(json['viewer_count'] as num).toInt(),
|
||||
json['started_at'] as String,
|
||||
json['thumbnail_url'] as String,
|
||||
json['language'] as String? ?? '',
|
||||
(json['tags'] as List<dynamic>?)?.map((e) => e as String).toList() ?? [],
|
||||
);
|
||||
|
||||
StreamsTwitch _$StreamsTwitchFromJson(Map<String, dynamic> json) =>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,21 +1,14 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart';
|
||||
import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart';
|
||||
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
||||
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
|
||||
import 'package:frosty/screens/channel/chat/widgets/chat_bottom_bar.dart';
|
||||
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
|
||||
import 'package:frosty/utils.dart';
|
||||
import 'package:frosty/utils/context_extensions.dart';
|
||||
import 'package:frosty/widgets/frosty_page_view.dart';
|
||||
import 'package:frosty/widgets/frosty_scrollbar.dart';
|
||||
|
||||
/// ~2x default to keep richer message widgets (emotes, badges, replies) built
|
||||
/// ahead of fast scroll-back without holding the entire history.
|
||||
const _chatCacheExtent = ScrollCacheExtent.pixels(500.0);
|
||||
|
||||
class Chat extends StatelessWidget {
|
||||
final ChatStore chatStore;
|
||||
final EdgeInsetsGeometry? listPadding;
|
||||
|
|
@ -24,20 +17,13 @@ class Chat extends StatelessWidget {
|
|||
/// Passes this to ChatBottomBar for the ChatDetails menu.
|
||||
final VoidCallback onAddChat;
|
||||
|
||||
/// When set, the widget operates in merged mode: messages come from all tabs
|
||||
/// and the active tab determines the send target.
|
||||
final ChatTabsStore? chatTabsStore;
|
||||
|
||||
const Chat({
|
||||
super.key,
|
||||
required this.chatStore,
|
||||
this.listPadding,
|
||||
required this.onAddChat,
|
||||
this.chatTabsStore,
|
||||
});
|
||||
|
||||
bool get _isMerged => chatTabsStore != null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Observer(
|
||||
|
|
@ -45,21 +31,18 @@ class Chat extends StatelessWidget {
|
|||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Stack(
|
||||
alignment: AlignmentDirectional.bottomCenter,
|
||||
children: [
|
||||
// Wrap only the message list with GestureDetector
|
||||
// so taps on ChatBottomBar don't trigger unfocus
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
if (chatStore.assetsStore.showEmoteMenu) {
|
||||
chatStore.assetsStore.showEmoteMenu = false;
|
||||
} else if (chatStore.textFieldFocusNode.hasFocus) {
|
||||
chatStore.unfocusInput();
|
||||
}
|
||||
},
|
||||
child: MediaQuery(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (chatStore.assetsStore.showEmoteMenu) {
|
||||
chatStore.assetsStore.showEmoteMenu = false;
|
||||
} else if (chatStore.textFieldFocusNode.hasFocus) {
|
||||
chatStore.unfocusInput();
|
||||
}
|
||||
},
|
||||
child: Stack(
|
||||
alignment: AlignmentDirectional.bottomCenter,
|
||||
children: [
|
||||
MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
textScaler: chatStore.settings.messageScale.textScaler,
|
||||
),
|
||||
|
|
@ -67,313 +50,172 @@ class Chat extends StatelessWidget {
|
|||
style: context.defaultTextStyle.copyWith(
|
||||
fontSize: chatStore.settings.fontSize,
|
||||
),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
// Don't add bottom padding in horizontal landscape
|
||||
// (immersive mode with home indicator on side).
|
||||
// landscapeForceVerticalChat uses portrait layout
|
||||
// with normal system UI, so still needs padding.
|
||||
final isHorizontalLandscape =
|
||||
context.isLandscape &&
|
||||
!chatStore.settings.landscapeForceVerticalChat;
|
||||
final bottomPadding =
|
||||
chatStore.assetsStore.showEmoteMenu ||
|
||||
isHorizontalLandscape
|
||||
? 0.0
|
||||
: MediaQuery.of(context).padding.bottom;
|
||||
|
||||
final scrollController = _isMerged
|
||||
? chatTabsStore!.mergedScrollController
|
||||
: chatStore.scrollController;
|
||||
|
||||
// Freeze the message list while a finger rests on a
|
||||
// message so a long-press target can't scroll out
|
||||
// from under it (which would dispose its recognizer
|
||||
// mid-press). The store resumes flowing as soon as
|
||||
// the pointer moves like a scroll, so scrolling is
|
||||
// never interrupted — only a stationary hold pauses.
|
||||
void onDown(PointerDownEvent e) => _isMerged
|
||||
? chatTabsStore!.onMessageListPointerDown(
|
||||
e.pointer,
|
||||
e.position,
|
||||
)
|
||||
: chatStore.onMessageListPointerDown(
|
||||
e.pointer,
|
||||
e.position,
|
||||
);
|
||||
void onMove(PointerMoveEvent e) => _isMerged
|
||||
? chatTabsStore!.onMessageListPointerMove(
|
||||
e.pointer,
|
||||
e.position,
|
||||
)
|
||||
: chatStore.onMessageListPointerMove(
|
||||
e.pointer,
|
||||
e.position,
|
||||
);
|
||||
void onUp(int pointer) => _isMerged
|
||||
? chatTabsStore!.onMessageListPointerUp(pointer)
|
||||
: chatStore.onMessageListPointerUp(pointer);
|
||||
|
||||
return Listener(
|
||||
onPointerDown: onDown,
|
||||
onPointerMove: onMove,
|
||||
onPointerUp: (e) => onUp(e.pointer),
|
||||
onPointerCancel: (e) => onUp(e.pointer),
|
||||
child: FrostyScrollbar(
|
||||
controller: scrollController,
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top,
|
||||
bottom:
|
||||
chatStore.bottomBarHeight + bottomPadding,
|
||||
child: FrostyScrollbar(
|
||||
controller: chatStore.scrollController,
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top,
|
||||
bottom:
|
||||
68 +
|
||||
(chatStore.assetsStore.showEmoteMenu
|
||||
? 0
|
||||
: MediaQuery.of(context).padding.bottom),
|
||||
),
|
||||
child: Observer(
|
||||
builder: (context) {
|
||||
return ListView.builder(
|
||||
reverse: true,
|
||||
padding: (listPadding ?? EdgeInsets.zero).add(
|
||||
EdgeInsets.only(
|
||||
bottom:
|
||||
68 +
|
||||
(chatStore.assetsStore.showEmoteMenu
|
||||
? 0
|
||||
: MediaQuery.of(
|
||||
context,
|
||||
).padding.bottom),
|
||||
),
|
||||
),
|
||||
child: Observer(
|
||||
builder: (context) {
|
||||
return _isMerged
|
||||
? _buildMergedList(
|
||||
scrollController,
|
||||
bottomPadding,
|
||||
)
|
||||
: _buildNormalList(
|
||||
scrollController,
|
||||
bottomPadding,
|
||||
);
|
||||
},
|
||||
addAutomaticKeepAlives: false,
|
||||
controller: chatStore.scrollController,
|
||||
itemCount: chatStore.renderMessages.length,
|
||||
itemBuilder: (context, index) => ChatMessage(
|
||||
ircMessage:
|
||||
chatStore.renderMessages[chatStore
|
||||
.renderMessages
|
||||
.length -
|
||||
1 -
|
||||
index],
|
||||
chatStore: chatStore,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: ChatBottomBar(
|
||||
chatStore: chatStore,
|
||||
onAddChat: onAddChat,
|
||||
channelDisplayName: _isMerged
|
||||
? getReadableName(
|
||||
chatTabsStore!.activeTab.displayName,
|
||||
chatTabsStore!.activeTab.channelLogin,
|
||||
)
|
||||
: null,
|
||||
// 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(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: ChatBottomBar(
|
||||
chatStore: chatStore,
|
||||
onAddChat: onAddChat,
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildResumeScrollButton(),
|
||||
],
|
||||
AnimatedPadding(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: EdgeInsets.only(
|
||||
left: 4,
|
||||
top: 4,
|
||||
right: 4,
|
||||
bottom:
|
||||
68 +
|
||||
(chatStore.assetsStore.showEmoteMenu
|
||||
? 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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
_buildEmoteMenu(context),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -416,7 +416,6 @@ class _ChatDetailsState extends State<ChatDetails> {
|
|||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: Settings.routeName),
|
||||
builder: (context) =>
|
||||
Settings(settingsStore: widget.chatStore.settings),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:frosty/models/irc.dart';
|
||||
import 'package:frosty/utils/context_extensions.dart';
|
||||
|
||||
class ChatModes extends StatelessWidget {
|
||||
final ROOMSTATE roomState;
|
||||
|
|
@ -28,7 +27,7 @@ class ChatModes extends StatelessWidget {
|
|||
label: 'Emote only',
|
||||
activeLabel: 'Emote only',
|
||||
isActive: true,
|
||||
activeColor: context.frostyColors.emoteOnly,
|
||||
activeColor: const Color(0xFFFFB74D), // Slightly lighter orange
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -45,7 +44,7 @@ class ChatModes extends StatelessWidget {
|
|||
label: 'Follower only',
|
||||
activeLabel: 'Follower only',
|
||||
isActive: true,
|
||||
activeColor: context.frostyColors.followersOnly,
|
||||
activeColor: const Color(0xFFF44336), // Bright red
|
||||
duration: _getFollowersDuration(),
|
||||
),
|
||||
),
|
||||
|
|
@ -63,7 +62,7 @@ class ChatModes extends StatelessWidget {
|
|||
label: 'Slow mode',
|
||||
activeLabel: 'Slow mode',
|
||||
isActive: true,
|
||||
activeColor: context.frostyColors.slowMode,
|
||||
activeColor: const Color(0xFF2196F3), // Bright blue
|
||||
duration: _getSlowModeDuration(),
|
||||
),
|
||||
),
|
||||
|
|
@ -81,7 +80,7 @@ class ChatModes extends StatelessWidget {
|
|||
label: 'Sub only',
|
||||
activeLabel: 'Sub only',
|
||||
isActive: true,
|
||||
activeColor: context.frostyColors.subOnly,
|
||||
activeColor: const Color(0xFF4CAF50), // Bright green
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -98,7 +97,7 @@ class ChatModes extends StatelessWidget {
|
|||
label: 'Unique mode',
|
||||
activeLabel: 'Unique mode',
|
||||
isActive: true,
|
||||
activeColor: context.frostyColors.uniqueMode,
|
||||
activeColor: const Color(0xFFAB47BC), // Slightly lighter purple
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -171,7 +170,7 @@ class ChatModes extends StatelessWidget {
|
|||
style: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import 'package:frosty/screens/settings/stores/settings_store.dart';
|
|||
import 'package:frosty/utils/context_extensions.dart';
|
||||
import 'package:frosty/widgets/alert_message.dart';
|
||||
import 'package:frosty/widgets/frosty_cached_network_image.dart';
|
||||
import 'package:frosty/widgets/frosty_dialog.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class RecentEmotesPanel extends StatelessWidget {
|
||||
|
|
@ -15,74 +16,116 @@ class RecentEmotesPanel extends StatelessWidget {
|
|||
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return Observer(
|
||||
builder: (context) {
|
||||
if (chatStore.assetsStore.recentEmotes.isEmpty) {
|
||||
return const Center(
|
||||
child: AlertMessage(message: 'No recent emotes'),
|
||||
);
|
||||
}
|
||||
|
||||
return MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: GridView.builder(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: context.isPortrait
|
||||
? 8
|
||||
: context.read<SettingsStore>().showVideo
|
||||
? 6
|
||||
: 16,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
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,
|
||||
);
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
if (chatStore.assetsStore.recentEmotes.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: AlertMessage(message: 'No recent emotes'),
|
||||
)
|
||||
else
|
||||
SliverGrid.builder(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: context.isPortrait
|
||||
? 8
|
||||
: context.read<SettingsStore>().showVideo
|
||||
? 6
|
||||
: 16,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
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(
|
||||
onTap: matchingEmotes.isNotEmpty
|
||||
? () => chatStore.addEmote(emote)
|
||||
: null,
|
||||
onLongPress: () {
|
||||
HapticFeedback.lightImpact();
|
||||
return InkWell(
|
||||
onTap: matchingEmotes.isNotEmpty
|
||||
? () => chatStore.addEmote(emote)
|
||||
: null,
|
||||
onLongPress: () {
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
IRCMessage.showEmoteDetailsBottomSheet(
|
||||
context,
|
||||
emote: emote,
|
||||
launchExternal: chatStore.settings.launchUrlExternal,
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Center(
|
||||
child: FrostyCachedNetworkImage(
|
||||
imageUrl: matchingEmotes.isNotEmpty
|
||||
? matchingEmotes.first.url
|
||||
: emote.url,
|
||||
color: matchingEmotes.isNotEmpty
|
||||
? null
|
||||
: const Color.fromRGBO(255, 255, 255, 0.5),
|
||||
colorBlendMode: matchingEmotes.isNotEmpty
|
||||
? null
|
||||
: BlendMode.modulate,
|
||||
height:
|
||||
emote.height?.toDouble() ?? defaultEmoteSize,
|
||||
width: emote.width?.toDouble(),
|
||||
IRCMessage.showEmoteDetailsBottomSheet(
|
||||
context,
|
||||
emote: emote,
|
||||
launchExternal: chatStore.settings.launchUrlExternal,
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Center(
|
||||
child: FrostyCachedNetworkImage(
|
||||
imageUrl: matchingEmotes.isNotEmpty
|
||||
? matchingEmotes.first.url
|
||||
: emote.url,
|
||||
color: matchingEmotes.isNotEmpty
|
||||
? null
|
||||
: const Color.fromRGBO(255, 255, 255, 0.5),
|
||||
colorBlendMode: matchingEmotes.isNotEmpty
|
||||
? null
|
||||
: BlendMode.modulate,
|
||||
height:
|
||||
emote.height?.toDouble() ?? defaultEmoteSize,
|
||||
width: emote.width?.toDouble(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
itemCount: chatStore.assetsStore.recentEmotes.length,
|
||||
),
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: TextButton.icon(
|
||||
onPressed: () => _showClearDialog(context),
|
||||
icon: const Icon(Icons.clear_all_rounded),
|
||||
label: const Text('Clear recent emotes'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
itemCount: chatStore.assetsStore.recentEmotes.length,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -130,7 +130,7 @@ abstract class ChatAssetsStoreBase with Store {
|
|||
|
||||
@action
|
||||
Future<void> init() async {
|
||||
// Load SharedPreferences for recent emotes.
|
||||
// Retrieve the instance that will allow us to retrieve local search history.
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
_recentEmotes =
|
||||
|
|
@ -290,18 +290,6 @@ abstract class ChatAssetsStoreBase with Store {
|
|||
await Future.wait(futures);
|
||||
}
|
||||
|
||||
/// Whether any shared chat participant assets have been merged in.
|
||||
bool get hasLoadedSharedChatAssets => _loadedSharedChannelIds.isNotEmpty;
|
||||
|
||||
/// Clears tracking of merged shared chat participant assets so that leaving
|
||||
/// and later re-entering a shared chat session re-fetches cleanly. The caller
|
||||
/// must re-fetch this channel's own assets afterwards (which replaces the
|
||||
/// merged emote/badge maps). Must run within a MobX action.
|
||||
void resetSharedChatAssets() {
|
||||
_loadedSharedChannelIds.clear();
|
||||
sharedSevenTvSetIds.clear();
|
||||
}
|
||||
|
||||
/// Load and cache shared chat participants for a broadcaster.
|
||||
/// Returns the list of new participant broadcaster IDs (deduped).
|
||||
@action
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
String toString() {
|
||||
return '''
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -17,29 +17,6 @@ mixin _$ChatStore on ChatStoreBase, Store {
|
|||
() => super.renderMessages,
|
||||
name: 'ChatStoreBase.renderMessages',
|
||||
)).value;
|
||||
Computed<List<Emote>>? _$matchingEmotesComputed;
|
||||
|
||||
@override
|
||||
List<Emote> get matchingEmotes =>
|
||||
(_$matchingEmotesComputed ??= Computed<List<Emote>>(
|
||||
() => super.matchingEmotes,
|
||||
name: 'ChatStoreBase.matchingEmotes',
|
||||
)).value;
|
||||
Computed<List<String>>? _$matchingChattersComputed;
|
||||
|
||||
@override
|
||||
List<String> get matchingChatters =>
|
||||
(_$matchingChattersComputed ??= Computed<List<String>>(
|
||||
() => super.matchingChatters,
|
||||
name: 'ChatStoreBase.matchingChatters',
|
||||
)).value;
|
||||
Computed<double>? _$bottomBarHeightComputed;
|
||||
|
||||
@override
|
||||
double get bottomBarHeight => (_$bottomBarHeightComputed ??= Computed<double>(
|
||||
() => super.bottomBarHeight,
|
||||
name: 'ChatStoreBase.bottomBarHeight',
|
||||
)).value;
|
||||
|
||||
late final _$timeRemainingAtom = Atom(
|
||||
name: 'ChatStoreBase.timeRemaining',
|
||||
|
|
@ -207,6 +184,26 @@ mixin _$ChatStore on ChatStoreBase, Store {
|
|||
);
|
||||
}
|
||||
|
||||
late final _$_isSendingMessageAtom = Atom(
|
||||
name: 'ChatStoreBase._isSendingMessage',
|
||||
context: context,
|
||||
);
|
||||
|
||||
bool get isSendingMessage {
|
||||
_$_isSendingMessageAtom.reportRead();
|
||||
return super._isSendingMessage;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get _isSendingMessage => isSendingMessage;
|
||||
|
||||
@override
|
||||
set _isSendingMessage(bool value) {
|
||||
_$_isSendingMessageAtom.reportWrite(value, super._isSendingMessage, () {
|
||||
super._isSendingMessage = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_isInSharedChatModeAtom = Atom(
|
||||
name: 'ChatStoreBase._isInSharedChatMode',
|
||||
context: context,
|
||||
|
|
@ -227,66 +224,6 @@ mixin _$ChatStore on ChatStoreBase, Store {
|
|||
});
|
||||
}
|
||||
|
||||
late final _$_isWaitingForAckAtom = Atom(
|
||||
name: 'ChatStoreBase._isWaitingForAck',
|
||||
context: context,
|
||||
);
|
||||
|
||||
bool get isWaitingForAck {
|
||||
_$_isWaitingForAckAtom.reportRead();
|
||||
return super._isWaitingForAck;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get _isWaitingForAck => isWaitingForAck;
|
||||
|
||||
@override
|
||||
set _isWaitingForAck(bool value) {
|
||||
_$_isWaitingForAckAtom.reportWrite(value, super._isWaitingForAck, () {
|
||||
super._isWaitingForAck = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_isConnectedAtom = Atom(
|
||||
name: 'ChatStoreBase._isConnected',
|
||||
context: context,
|
||||
);
|
||||
|
||||
bool get isConnected {
|
||||
_$_isConnectedAtom.reportRead();
|
||||
return super._isConnected;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get _isConnected => isConnected;
|
||||
|
||||
@override
|
||||
set _isConnected(bool value) {
|
||||
_$_isConnectedAtom.reportWrite(value, super._isConnected, () {
|
||||
super._isConnected = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_hasConnectedAtom = Atom(
|
||||
name: 'ChatStoreBase._hasConnected',
|
||||
context: context,
|
||||
);
|
||||
|
||||
bool get hasConnected {
|
||||
_$_hasConnectedAtom.reportRead();
|
||||
return super._hasConnected;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get _hasConnected => hasConnected;
|
||||
|
||||
@override
|
||||
set _hasConnected(bool value) {
|
||||
_$_hasConnectedAtom.reportWrite(value, super._hasConnected, () {
|
||||
super._hasConnected = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_userStateAtom = Atom(
|
||||
name: 'ChatStoreBase._userState',
|
||||
context: context,
|
||||
|
|
@ -572,10 +509,7 @@ mixin _$ChatStore on ChatStoreBase, Store {
|
|||
timeRemaining: ${timeRemaining},
|
||||
expandChat: ${expandChat},
|
||||
replyingToMessage: ${replyingToMessage},
|
||||
renderMessages: ${renderMessages},
|
||||
matchingEmotes: ${matchingEmotes},
|
||||
matchingChatters: ${matchingChatters},
|
||||
bottomBarHeight: ${bottomBarHeight}
|
||||
renderMessages: ${renderMessages}
|
||||
''';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,9 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:frosty/apis/bttv_api.dart';
|
||||
import 'package:frosty/apis/ffz_api.dart';
|
||||
import 'package:frosty/apis/seventv_api.dart';
|
||||
import 'package:frosty/apis/twitch_api.dart';
|
||||
import 'package:frosty/models/irc.dart';
|
||||
import 'package:frosty/models/user.dart';
|
||||
import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
|
||||
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
|
||||
import 'package:frosty/screens/channel/chat/stores/chat_interaction_pause.dart';
|
||||
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
|
|
@ -56,10 +50,6 @@ class ChatTabInfo {
|
|||
/// The ChatStore for this tab. Null until the tab is activated (lazy loading).
|
||||
ChatStore? chatStore;
|
||||
|
||||
/// The id of the most recent message visible the last time the user viewed
|
||||
/// this tab. Used to compute unread state — null means "no baseline yet".
|
||||
String? lastSeenMessageId;
|
||||
|
||||
/// Whether this is the primary tab (first tab, cannot be removed).
|
||||
final bool isPrimary;
|
||||
|
||||
|
|
@ -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.
|
||||
class ChatTabsStore = ChatTabsStoreBase with _$ChatTabsStore;
|
||||
|
||||
|
|
@ -90,9 +72,6 @@ abstract class ChatTabsStoreBase with Store {
|
|||
/// Maximum number of tabs allowed.
|
||||
static const maxTabs = 10;
|
||||
|
||||
/// Max messages to render in merged view when autoscrolling.
|
||||
static const _mergedRenderLimit = 100;
|
||||
|
||||
/// API services needed for creating ChatStore instances.
|
||||
final TwitchApi twitchApi;
|
||||
final BTTVApi bttvApi;
|
||||
|
|
@ -129,175 +108,6 @@ abstract class ChatTabsStoreBase with Store {
|
|||
@computed
|
||||
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.
|
||||
List<ChatTabInfo> get tabs => _tabs;
|
||||
|
||||
|
|
@ -314,9 +124,7 @@ abstract class ChatTabsStoreBase with Store {
|
|||
required String primaryDisplayName,
|
||||
}) {
|
||||
// Enable wakelock once for all chat tabs
|
||||
if (settingsStore.keepScreenAwake) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
WakelockPlus.enable();
|
||||
|
||||
// Initialize with the primary channel tab
|
||||
_addPrimaryTab(
|
||||
|
|
@ -329,26 +137,6 @@ abstract class ChatTabsStoreBase with Store {
|
|||
if (settingsStore.persistChatTabs) {
|
||||
_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.
|
||||
|
|
@ -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.
|
||||
@action
|
||||
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;
|
||||
setActiveTab(newIndex);
|
||||
|
||||
// If in merged mode, fetch the new tab's channel profile
|
||||
if (mergedMode) {
|
||||
_fetchTabChannelProfile(_tabs[newIndex]);
|
||||
}
|
||||
activateTab(newIndex);
|
||||
activeTabIndex = newIndex;
|
||||
|
||||
// Sync to settings for persistence
|
||||
_syncSecondaryTabsToSettings();
|
||||
|
|
@ -557,39 +285,6 @@ abstract class ChatTabsStoreBase with Store {
|
|||
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.
|
||||
/// Returns true if removed, false if primary tab or invalid index.
|
||||
@action
|
||||
|
|
@ -604,25 +299,12 @@ abstract class ChatTabsStoreBase with Store {
|
|||
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)
|
||||
_tabs[index].chatStore?.dispose();
|
||||
|
||||
// Remove the tab
|
||||
_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
|
||||
if (activeTabIndex >= _tabs.length) {
|
||||
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
|
||||
if (oldIndex == 0 || newIndex == 0) return;
|
||||
|
||||
// onReorderItem already adjusts newIndex for the removed item, so no
|
||||
// manual off-by-one correction is needed here.
|
||||
// ReorderableListView passes newIndex as if item was already removed
|
||||
if (oldIndex < newIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
if (oldIndex == newIndex) return;
|
||||
|
||||
final tab = _tabs.removeAt(oldIndex);
|
||||
|
|
@ -665,54 +349,12 @@ abstract class ChatTabsStoreBase with Store {
|
|||
_syncSecondaryTabsToSettings();
|
||||
}
|
||||
|
||||
/// Returns the last message id in [messages] that has a non-null tag id,
|
||||
/// or null if no such message exists. Skips system messages without ids.
|
||||
String? _latestMessageId(List<IRCMessage> messages) {
|
||||
for (var i = messages.length - 1; i >= 0; i--) {
|
||||
final id = messages[i].tags['id'];
|
||||
if (id != null) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// True if [index] has unread messages since the user last viewed it.
|
||||
/// Always false for the active tab and in merged mode.
|
||||
bool hasUnreadMessages(int index) {
|
||||
if (mergedMode) return false;
|
||||
if (index == activeTabIndex) return false;
|
||||
if (index < 0 || index >= _tabs.length) return false;
|
||||
final tab = _tabs[index];
|
||||
final store = tab.chatStore;
|
||||
if (store == null) return false;
|
||||
final latest = _latestMessageId(store.messages);
|
||||
if (latest == null) return false;
|
||||
return latest != tab.lastSeenMessageId;
|
||||
}
|
||||
|
||||
/// Sets the active tab to the given index.
|
||||
///
|
||||
/// When [silent] is true, the current tab's draft text and reply state are
|
||||
/// preserved. Used for programmatic switches (e.g., reply/paste routing in
|
||||
/// merged mode) where wiping the draft would lose user work.
|
||||
@action
|
||||
void setActiveTab(int index, {bool silent = false}) {
|
||||
void setActiveTab(int index) {
|
||||
if (index >= 0 && index < _tabs.length) {
|
||||
// Snapshot the outgoing tab's most recent message id so the unread
|
||||
// dot for that tab clears, and only future messages count as unread.
|
||||
if (index != activeTabIndex &&
|
||||
activeTabIndex >= 0 &&
|
||||
activeTabIndex < _tabs.length) {
|
||||
final outgoing = _tabs[activeTabIndex];
|
||||
final outStore = outgoing.chatStore;
|
||||
if (outStore != null) {
|
||||
outgoing.lastSeenMessageId = _latestMessageId(outStore.messages);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear text input and emote menu when switching tabs.
|
||||
// Skip in merged mode — tabs act as send-target selectors, not
|
||||
// view switches, so draft/reply state should be preserved.
|
||||
if (index != activeTabIndex && !silent && !mergedMode) {
|
||||
// Clear text input and emote menu when switching tabs
|
||||
if (index != activeTabIndex) {
|
||||
final currentStore = _tabs[activeTabIndex].chatStore;
|
||||
if (currentStore != null) {
|
||||
// Close emote menu if open
|
||||
|
|
@ -729,24 +371,16 @@ abstract class ChatTabsStoreBase with Store {
|
|||
// Activate the target tab if not already activated
|
||||
activateTab(index);
|
||||
|
||||
if (mergedMode) {
|
||||
_fetchTabChannelProfile(_tabs[index]);
|
||||
}
|
||||
|
||||
activeTabIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
/// Disposes all ChatStores and cleans up resources.
|
||||
void dispose() {
|
||||
_mergedRenderTimer?.cancel();
|
||||
_mergedRenderTimer = null;
|
||||
_interactionPause.reset();
|
||||
for (final tab in _tabs) {
|
||||
tab.chatStore?.dispose();
|
||||
}
|
||||
_tabs.clear();
|
||||
mergedScrollController.dispose();
|
||||
|
||||
// Disable wakelock once after all tabs disposed
|
||||
WakelockPlus.disable();
|
||||
|
|
|
|||
|
|
@ -56,31 +56,6 @@ mixin _$ChatTabsStore on ChatTabsStoreBase, Store {
|
|||
() => super.showTabBar,
|
||||
name: 'ChatTabsStoreBase.showTabBar',
|
||||
)).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(
|
||||
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(
|
||||
name: 'ChatTabsStoreBase',
|
||||
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
|
||||
void activateTab(int index) {
|
||||
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
|
||||
bool removeTab(int index) {
|
||||
final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction(
|
||||
|
|
@ -292,12 +157,12 @@ mixin _$ChatTabsStore on ChatTabsStoreBase, Store {
|
|||
}
|
||||
|
||||
@override
|
||||
void setActiveTab(int index, {bool silent = false}) {
|
||||
void setActiveTab(int index) {
|
||||
final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction(
|
||||
name: 'ChatTabsStoreBase.setActiveTab',
|
||||
);
|
||||
try {
|
||||
return super.setActiveTab(index, silent: silent);
|
||||
return super.setActiveTab(index);
|
||||
} finally {
|
||||
_$ChatTabsStoreBaseActionController.endAction(_$actionInfo);
|
||||
}
|
||||
|
|
@ -307,14 +172,10 @@ mixin _$ChatTabsStore on ChatTabsStoreBase, Store {
|
|||
String toString() {
|
||||
return '''
|
||||
activeTabIndex: ${activeTabIndex},
|
||||
mergedMode: ${mergedMode},
|
||||
activeTab: ${activeTab},
|
||||
activeChatStore: ${activeChatStore},
|
||||
canAddTab: ${canAddTab},
|
||||
showTabBar: ${showTabBar},
|
||||
mergedChannelIdToUserTwitch: ${mergedChannelIdToUserTwitch},
|
||||
mergedMessages: ${mergedMessages},
|
||||
mergedBufferCount: ${mergedBufferCount}
|
||||
showTabBar: ${showTabBar}
|
||||
''';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import 'package:extended_text_field/extended_text_field.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
|
|
@ -9,9 +8,7 @@ import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
|||
import 'package:frosty/utils/context_extensions.dart';
|
||||
import 'package:frosty/utils/modal_bottom_sheet.dart';
|
||||
import 'package:frosty/widgets/blurred_container.dart';
|
||||
import 'package:frosty/widgets/chat_input/emote_text_span_builder.dart';
|
||||
import 'package:frosty/widgets/frosty_cached_network_image.dart';
|
||||
import 'package:frosty/widgets/measure_size.dart';
|
||||
|
||||
class ChatBottomBar extends StatelessWidget {
|
||||
final ChatStore chatStore;
|
||||
|
|
@ -20,14 +17,10 @@ class ChatBottomBar extends StatelessWidget {
|
|||
/// Passes this to ChatDetails to show "Add chat" option.
|
||||
final VoidCallback onAddChat;
|
||||
|
||||
/// When set, indicates merged mode — shown in hint text (e.g., "Chat in xQc").
|
||||
final String? channelDisplayName;
|
||||
|
||||
const ChatBottomBar({
|
||||
super.key,
|
||||
required this.chatStore,
|
||||
required this.onAddChat,
|
||||
this.channelDisplayName,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -62,116 +55,113 @@ class ChatBottomBar extends StatelessWidget {
|
|||
|
||||
return Observer(
|
||||
builder: (context) {
|
||||
final matchingEmotes = chatStore.matchingEmotes;
|
||||
final matchingChatters = chatStore.matchingChatters;
|
||||
final matchingEmotes =
|
||||
[
|
||||
...chatStore.assetsStore.userEmoteToObject.values,
|
||||
...chatStore.assetsStore.bttvEmotes,
|
||||
...chatStore.assetsStore.ffzEmotes,
|
||||
...chatStore.assetsStore.sevenTVEmotes,
|
||||
]
|
||||
.where(
|
||||
(emote) => emote.name.toLowerCase().contains(
|
||||
chatStore.inputText.split(' ').last.toLowerCase(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
final matchingChatters = chatStore.chatDetailsStore.chatUsers
|
||||
.where(
|
||||
(chatter) => chatter.contains(
|
||||
chatStore.inputText
|
||||
.split(' ')
|
||||
.last
|
||||
.replaceFirst('@', '')
|
||||
.toLowerCase(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
final isFullscreenOverlay =
|
||||
chatStore.settings.fullScreen && context.isLandscape;
|
||||
|
||||
final effectiveChatDelay = chatStore.settings.effectiveChatDelay;
|
||||
|
||||
// Check if chat delay is active (for indicator only, doesn't block input)
|
||||
final hasChatDelay =
|
||||
chatStore.settings.showVideo && effectiveChatDelay > 0;
|
||||
|
||||
chatStore.settings.showVideo && chatStore.settings.chatDelay > 0;
|
||||
final delayTooltipMessage =
|
||||
'Chatting is disabled due to message delay (${chatStore.settings.chatDelay.toInt()}s)';
|
||||
const loginTooltipMessage = 'Log in to chat';
|
||||
|
||||
final bottomBarContent = Column(
|
||||
children: [
|
||||
if (chatStore.replyingToMessage != null) ...[
|
||||
const Divider(),
|
||||
TextFieldTapRegion(
|
||||
child: Container(
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
width: 4,
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.only(left: 16),
|
||||
leading: const Icon(Icons.reply),
|
||||
title: Tooltip(
|
||||
message: chatStore.replyingToMessage!.message,
|
||||
preferBelow: false,
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: chatStore.replyingToMessage!.generateSpan(
|
||||
context,
|
||||
assetsStore: chatStore.assetsStore,
|
||||
emoteScale: chatStore.settings.emoteScale,
|
||||
badgeScale: chatStore.settings.badgeScale,
|
||||
launchExternal: chatStore.settings.launchUrlExternal,
|
||||
timestamp: chatStore.settings.timestampType,
|
||||
currentChannelId: chatStore.channelId,
|
||||
),
|
||||
),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.defaultTextStyle.copyWith(
|
||||
fontSize: chatStore.settings.fontSize,
|
||||
),
|
||||
),
|
||||
// Left: 8px so text aligns at 12px (4px border + 8px padding)
|
||||
// Right: 4px to give close button some breathing room
|
||||
padding: const EdgeInsets.only(left: 8, right: 4),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Tooltip(
|
||||
message: chatStore.replyingToMessage!.message ?? '',
|
||||
preferBelow: false,
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: chatStore.replyingToMessage!
|
||||
.generateSpan(
|
||||
context,
|
||||
assetsStore: chatStore.assetsStore,
|
||||
emoteScale: chatStore.settings.emoteScale,
|
||||
badgeScale: chatStore.settings.badgeScale,
|
||||
launchExternal:
|
||||
chatStore.settings.launchUrlExternal,
|
||||
timestamp: chatStore.settings.timestampType,
|
||||
currentChannelId: chatStore.channelId,
|
||||
),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.defaultTextStyle.copyWith(
|
||||
fontSize: chatStore.settings.fontSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Cancel reply',
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () => chatStore.replyingToMessage = null,
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
trailing: IconButton(
|
||||
tooltip: 'Cancel reply',
|
||||
onPressed: () {
|
||||
chatStore.replyingToMessage = null;
|
||||
},
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
),
|
||||
],
|
||||
// Wrap autocomplete sections with TextFieldTapRegion so taps
|
||||
// on them don't trigger TextField's onTapOutside callback.
|
||||
if (chatStore.settings.autocomplete &&
|
||||
chatStore.showEmoteAutocomplete &&
|
||||
matchingEmotes.isNotEmpty) ...[
|
||||
const Divider(),
|
||||
TextFieldTapRegion(
|
||||
child: SizedBox(
|
||||
height: 50,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(4),
|
||||
itemCount: matchingEmotes.length,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (context, index) => InkWell(
|
||||
onTap: () => chatStore.addEmote(
|
||||
matchingEmotes[index],
|
||||
autocompleteMode: true,
|
||||
),
|
||||
onLongPress: () {
|
||||
HapticFeedback.lightImpact();
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(4),
|
||||
itemCount: matchingEmotes.length,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (context, index) => InkWell(
|
||||
onTap: () => chatStore.addEmote(
|
||||
matchingEmotes[index],
|
||||
autocompleteMode: true,
|
||||
),
|
||||
onLongPress: () {
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
IRCMessage.showEmoteDetailsBottomSheet(
|
||||
context,
|
||||
emote: matchingEmotes[index],
|
||||
launchExternal: chatStore.settings.launchUrlExternal,
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Center(
|
||||
child: FrostyCachedNetworkImage(
|
||||
imageUrl: matchingEmotes[index].url,
|
||||
useFade: false,
|
||||
height:
|
||||
matchingEmotes[index].height?.toDouble() ??
|
||||
defaultEmoteSize,
|
||||
width: matchingEmotes[index].width?.toDouble(),
|
||||
),
|
||||
IRCMessage.showEmoteDetailsBottomSheet(
|
||||
context,
|
||||
emote: matchingEmotes[index],
|
||||
launchExternal: chatStore.settings.launchUrlExternal,
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Center(
|
||||
child: FrostyCachedNetworkImage(
|
||||
imageUrl: matchingEmotes[index].url,
|
||||
useFade: false,
|
||||
height:
|
||||
matchingEmotes[index].height?.toDouble() ??
|
||||
defaultEmoteSize,
|
||||
width: matchingEmotes[index].width?.toDouble(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -183,32 +173,30 @@ class ChatBottomBar extends StatelessWidget {
|
|||
chatStore.showMentionAutocomplete &&
|
||||
matchingChatters.isNotEmpty) ...[
|
||||
const Divider(),
|
||||
TextFieldTapRegion(
|
||||
child: SizedBox(
|
||||
height: 50,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(4),
|
||||
itemCount: matchingChatters.length,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (context, index) => TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
onPressed: () {
|
||||
final split = chatStore.textController.text.split(' ')
|
||||
..removeLast()
|
||||
..add('@${matchingChatters[index]} ');
|
||||
|
||||
chatStore.textController.text = split.join(' ');
|
||||
chatStore.textController.selection =
|
||||
TextSelection.fromPosition(
|
||||
TextPosition(
|
||||
offset: chatStore.textController.text.length,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text(matchingChatters[index]),
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(4),
|
||||
itemCount: matchingChatters.length,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (context, index) => TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
onPressed: () {
|
||||
final split = chatStore.textController.text.split(' ')
|
||||
..removeLast()
|
||||
..add('@${matchingChatters[index]} ');
|
||||
|
||||
chatStore.textController.text = split.join(' ');
|
||||
chatStore.textController.selection =
|
||||
TextSelection.fromPosition(
|
||||
TextPosition(
|
||||
offset: chatStore.textController.text.length,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text(matchingChatters[index]),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -223,14 +211,16 @@ class ChatBottomBar extends StatelessWidget {
|
|||
context.isLandscape)
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final isDisabled = !chatStore.auth.isLoggedIn;
|
||||
final isDisabled =
|
||||
!chatStore.auth.isLoggedIn || hasChatDelay;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: isDisabled
|
||||
? () {
|
||||
chatStore.updateNotification(
|
||||
loginTooltipMessage,
|
||||
);
|
||||
final message = hasChatDelay
|
||||
? delayTooltipMessage
|
||||
: loginTooltipMessage;
|
||||
chatStore.updateNotification(message);
|
||||
}
|
||||
: null,
|
||||
child: IconButton(
|
||||
|
|
@ -250,36 +240,27 @@ class ChatBottomBar extends StatelessWidget {
|
|||
Expanded(
|
||||
child: Observer(
|
||||
builder: (context) {
|
||||
final isLoggedIn = chatStore.auth.isLoggedIn;
|
||||
final isWaitingForAck = chatStore.isWaitingForAck;
|
||||
final isConnected = chatStore.isConnected;
|
||||
final hasConnected = chatStore.hasConnected;
|
||||
final isEnabled =
|
||||
isLoggedIn && !isWaitingForAck && isConnected;
|
||||
final isDisabled =
|
||||
!chatStore.auth.isLoggedIn ||
|
||||
chatStore.isSendingMessage ||
|
||||
hasChatDelay;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: !isLoggedIn
|
||||
onTap: isDisabled && !chatStore.isSendingMessage
|
||||
? () {
|
||||
chatStore.updateNotification(
|
||||
loginTooltipMessage,
|
||||
);
|
||||
final message = hasChatDelay
|
||||
? delayTooltipMessage
|
||||
: loginTooltipMessage;
|
||||
chatStore.updateNotification(message);
|
||||
}
|
||||
: null,
|
||||
child: ExtendedTextField(
|
||||
child: TextField(
|
||||
textInputAction: TextInputAction.send,
|
||||
focusNode: chatStore.textFieldFocusNode,
|
||||
minLines: 1,
|
||||
maxLines: 3,
|
||||
enabled: isEnabled,
|
||||
specialTextSpanBuilder: EmoteTextSpanBuilder(
|
||||
emoteToObject:
|
||||
chatStore.assetsStore.emoteToObject,
|
||||
userEmoteToObject:
|
||||
chatStore.assetsStore.userEmoteToObject,
|
||||
emoteSize:
|
||||
chatStore.settings.emoteScale *
|
||||
defaultEmoteSize,
|
||||
),
|
||||
// Disable text field when sending message, when not logged in, or when chat delay is active
|
||||
enabled: !isDisabled,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon:
|
||||
chatStore.settings.emoteMenuButtonOnLeft
|
||||
|
|
@ -297,25 +278,13 @@ class ChatBottomBar extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
hintMaxLines: 1,
|
||||
hintText: !isLoggedIn
|
||||
? loginTooltipMessage
|
||||
: !isConnected
|
||||
? (hasConnected
|
||||
? 'Chat disconnected'
|
||||
: 'Connecting...')
|
||||
: isWaitingForAck
|
||||
? 'Sending...'
|
||||
: chatStore.replyingToMessage != null
|
||||
? channelDisplayName != null
|
||||
? 'Reply in ${channelDisplayName!}'
|
||||
: 'Reply'
|
||||
: hasChatDelay
|
||||
? channelDisplayName != null
|
||||
? 'Chat in ${channelDisplayName!} (${effectiveChatDelay.toInt()}s delay)'
|
||||
: 'Chat (${effectiveChatDelay.toInt()}s delay)'
|
||||
: channelDisplayName != null
|
||||
? 'Chat in ${channelDisplayName!}'
|
||||
: 'Chat',
|
||||
hintText: chatStore.auth.isLoggedIn
|
||||
? chatStore.isSendingMessage
|
||||
? 'Sending...'
|
||||
: chatStore.replyingToMessage != null
|
||||
? 'Reply'
|
||||
: 'Chat'
|
||||
: loginTooltipMessage,
|
||||
),
|
||||
controller: chatStore.textController,
|
||||
onSubmitted: chatStore.sendMessage,
|
||||
|
|
@ -327,97 +296,74 @@ class ChatBottomBar extends StatelessWidget {
|
|||
},
|
||||
),
|
||||
),
|
||||
TextFieldTapRegion(
|
||||
child:
|
||||
chatStore.showSendButton &&
|
||||
(chatStore.settings.chatWidth >= 0.3 ||
|
||||
chatStore.expandChat ||
|
||||
context.isPortrait)
|
||||
? Observer(
|
||||
builder: (context) => IconButton(
|
||||
tooltip: chatStore.isWaitingForAck
|
||||
? 'Sending...'
|
||||
: 'Send',
|
||||
icon: chatStore.isWaitingForAck
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.send_rounded),
|
||||
onPressed:
|
||||
chatStore.auth.isLoggedIn &&
|
||||
!chatStore.isWaitingForAck &&
|
||||
chatStore.isConnected
|
||||
? () => chatStore.sendMessage(
|
||||
chatStore.textController.text,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
)
|
||||
: IconButton(
|
||||
icon: Icon(Icons.adaptive.more_rounded),
|
||||
tooltip: 'More',
|
||||
onPressed: () =>
|
||||
showModalBottomSheetWithProperFocus(
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (_) => ChatDetails(
|
||||
chatDetailsStore:
|
||||
chatStore.chatDetailsStore,
|
||||
chatStore: chatStore,
|
||||
userLogin: chatStore.channelName,
|
||||
onAddChat: onAddChat,
|
||||
if (chatStore.showSendButton &&
|
||||
(chatStore.settings.chatWidth >= 0.3 ||
|
||||
chatStore.expandChat ||
|
||||
context.isPortrait))
|
||||
Observer(
|
||||
builder: (context) {
|
||||
return IconButton(
|
||||
tooltip: chatStore.isSendingMessage
|
||||
? 'Sending...'
|
||||
: 'Send',
|
||||
icon: chatStore.isSendingMessage
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.send_rounded),
|
||||
onPressed:
|
||||
chatStore.auth.isLoggedIn &&
|
||||
!chatStore.isSendingMessage &&
|
||||
!hasChatDelay
|
||||
? () => chatStore.sendMessage(
|
||||
chatStore.textController.text,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
icon: Icon(Icons.adaptive.more_rounded),
|
||||
tooltip: 'More',
|
||||
onPressed: () => showModalBottomSheetWithProperFocus(
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (_) => ChatDetails(
|
||||
chatDetailsStore: chatStore.chatDetailsStore,
|
||||
chatStore: chatStore,
|
||||
userLogin: chatStore.channelName,
|
||||
onAddChat: onAddChat,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// Don't add bottom padding when:
|
||||
// - Emote menu is open: The emote menu provides its own bottom boundary
|
||||
// - In horizontal landscape mode: System is in immersive mode and home
|
||||
// indicator is on the side (left/right), not the content bottom.
|
||||
// SafeArea handles left/right insets for the notch.
|
||||
// Note: landscapeForceVerticalChat uses portrait layout in landscape
|
||||
// orientation with normal (non-immersive) system UI, so it still needs
|
||||
// bottom padding.
|
||||
final isHorizontalLandscape =
|
||||
context.isLandscape &&
|
||||
!chatStore.settings.landscapeForceVerticalChat;
|
||||
final needsBottomPadding =
|
||||
!chatStore.assetsStore.showEmoteMenu && !isHorizontalLandscape;
|
||||
|
||||
// Measured once here; the list pads itself by this height so the bar
|
||||
// never overlaps messages. Shared by both container branches below.
|
||||
final measuredBar = MeasureSize(
|
||||
onChange: (size) => chatStore.setBottomBarHeight(size.height),
|
||||
child: bottomBarContent,
|
||||
);
|
||||
|
||||
return isFullscreenOverlay
|
||||
? Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: needsBottomPadding
|
||||
? MediaQuery.of(context).padding.bottom
|
||||
: 0,
|
||||
bottom: chatStore.assetsStore.showEmoteMenu
|
||||
? 0
|
||||
: MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: measuredBar,
|
||||
child: bottomBarContent,
|
||||
)
|
||||
: BlurredContainer(
|
||||
gradientDirection: GradientDirection.down,
|
||||
padding: EdgeInsets.only(
|
||||
bottom: needsBottomPadding
|
||||
? MediaQuery.of(context).padding.bottom
|
||||
: 0,
|
||||
bottom: chatStore.assetsStore.showEmoteMenu
|
||||
? 0
|
||||
: MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: measuredBar,
|
||||
child: bottomBarContent,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,12 +5,9 @@ import 'package:flutter_mobx/flutter_mobx.dart';
|
|||
import 'package:frosty/apis/twitch_api.dart';
|
||||
import 'package:frosty/constants.dart';
|
||||
import 'package:frosty/models/irc.dart';
|
||||
import 'package:frosty/models/user.dart';
|
||||
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
||||
import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart';
|
||||
import 'package:frosty/screens/channel/chat/widgets/moderation_actions.dart';
|
||||
import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart';
|
||||
import 'package:frosty/utils/context_extensions.dart';
|
||||
import 'package:frosty/utils/modal_bottom_sheet.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
|
|
@ -18,36 +15,16 @@ class ChatMessage extends StatelessWidget {
|
|||
final IRCMessage ircMessage;
|
||||
final ChatStore chatStore;
|
||||
final bool isModal;
|
||||
final bool forceTimestamp;
|
||||
final bool showReplyHeader;
|
||||
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({
|
||||
super.key,
|
||||
required this.ircMessage,
|
||||
required this.chatStore,
|
||||
this.isModal = false,
|
||||
this.forceTimestamp = false,
|
||||
this.showReplyHeader = true,
|
||||
this.isInReplyThread = false,
|
||||
this.overrideChannelIdToUserTwitch,
|
||||
this.overrideCurrentChannelId,
|
||||
this.inputChatStore,
|
||||
this.onActivateSourceTab,
|
||||
});
|
||||
|
||||
void onTapName(BuildContext context) {
|
||||
|
|
@ -68,7 +45,6 @@ class ChatMessage extends StatelessWidget {
|
|||
username: ircMessage.user!,
|
||||
userId: ircMessage.tags['user-id']!,
|
||||
displayName: ircMessage.tags['display-name']!,
|
||||
onActivateSourceTab: onActivateSourceTab,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -88,33 +64,23 @@ class ChatMessage extends StatelessWidget {
|
|||
username: user.login,
|
||||
userId: user.id,
|
||||
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 {
|
||||
await Clipboard.setData(ClipboardData(text: ircMessage.message ?? ''));
|
||||
|
||||
_effectiveInputStore.updateNotification('Message copied');
|
||||
chatStore.updateNotification('Message copied');
|
||||
}
|
||||
|
||||
void onLongPressMessage(BuildContext context, TextStyle defaultTextStyle) {
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
// Allow the full menu for live messages and for ones marked deleted/cleared
|
||||
// by a moderation action — those keep their tags (user-id, id), so a
|
||||
// moderator can still act on them (e.g. remove a timeout). Everything else
|
||||
// (notices, system messages) just copies.
|
||||
if (ircMessage.command != Command.privateMessage &&
|
||||
ircMessage.command != Command.userState &&
|
||||
ircMessage.command != Command.clearChat &&
|
||||
ircMessage.command != Command.clearMessage) {
|
||||
ircMessage.command != Command.userState) {
|
||||
copyMessage();
|
||||
return;
|
||||
}
|
||||
|
|
@ -137,14 +103,9 @@ class ChatMessage extends StatelessWidget {
|
|||
badgeScale: chatStore.settings.badgeScale,
|
||||
launchExternal: chatStore.settings.launchUrlExternal,
|
||||
timestamp: chatStore.settings.timestampType,
|
||||
forceTimestamp: forceTimestamp,
|
||||
showHistoricalTimestamps:
|
||||
chatStore.settings.showHistoricalTimestamps,
|
||||
channelIdToUserTwitch:
|
||||
overrideChannelIdToUserTwitch ??
|
||||
chatStore.assetsStore.channelIdToUserTwitch,
|
||||
currentChannelId:
|
||||
overrideCurrentChannelId ?? chatStore.channelId,
|
||||
currentChannelId: chatStore.channelId,
|
||||
),
|
||||
style: defaultTextStyle,
|
||||
),
|
||||
|
|
@ -163,14 +124,13 @@ class ChatMessage extends StatelessWidget {
|
|||
onTap: () async {
|
||||
await copyMessage();
|
||||
|
||||
final effectiveChatDelay = chatStore.settings.effectiveChatDelay;
|
||||
|
||||
final hasChatDelay =
|
||||
chatStore.settings.showVideo && effectiveChatDelay > 0;
|
||||
chatStore.settings.showVideo &&
|
||||
chatStore.settings.chatDelay > 0;
|
||||
|
||||
if (hasChatDelay) {
|
||||
_effectiveInputStore.updateNotification(
|
||||
'Chatting is disabled due to message delay (${effectiveChatDelay.toInt()}s)',
|
||||
chatStore.updateNotification(
|
||||
'Chatting is disabled due to message delay (${chatStore.settings.chatDelay.toInt()}s)',
|
||||
);
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
|
|
@ -178,10 +138,6 @@ class ChatMessage extends StatelessWidget {
|
|||
return;
|
||||
}
|
||||
|
||||
// In merged mode, switch to the source tab so the paste
|
||||
// targets the correct channel's composer.
|
||||
onActivateSourceTab?.call();
|
||||
|
||||
// Paste the copied message into the text controller
|
||||
chatStore.textController.text = ircMessage.message ?? '';
|
||||
chatStore.safeRequestFocus();
|
||||
|
|
@ -194,11 +150,6 @@ class ChatMessage extends StatelessWidget {
|
|||
),
|
||||
ListTile(
|
||||
onTap: () {
|
||||
// In merged mode, switch to the source tab so the reply
|
||||
// targets the correct channel (reply-parent-msg-id is
|
||||
// channel-scoped).
|
||||
onActivateSourceTab?.call();
|
||||
|
||||
chatStore.replyingToMessage = ircMessage;
|
||||
chatStore.safeRequestFocus();
|
||||
Navigator.pop(context);
|
||||
|
|
@ -206,14 +157,6 @@ class ChatMessage extends StatelessWidget {
|
|||
leading: const Icon(Icons.reply),
|
||||
title: const Text('Reply to message'),
|
||||
),
|
||||
if (ircMessage.tags['user-id'] != null)
|
||||
ModerationActions(
|
||||
chatStore: chatStore,
|
||||
targetUserId: ircMessage.tags['user-id']!,
|
||||
targetName:
|
||||
ircMessage.tags['display-name'] ?? ircMessage.user ?? '',
|
||||
messageId: ircMessage.tags['id'],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -257,14 +200,9 @@ class ChatMessage extends StatelessWidget {
|
|||
badgeScale: chatStore.settings.badgeScale,
|
||||
launchExternal: chatStore.settings.launchUrlExternal,
|
||||
timestamp: chatStore.settings.timestampType,
|
||||
forceTimestamp: forceTimestamp,
|
||||
showHistoricalTimestamps:
|
||||
chatStore.settings.showHistoricalTimestamps,
|
||||
channelIdToUserTwitch:
|
||||
overrideChannelIdToUserTwitch ??
|
||||
chatStore.assetsStore.channelIdToUserTwitch,
|
||||
currentChannelId:
|
||||
overrideCurrentChannelId ?? chatStore.channelId,
|
||||
currentChannelId: chatStore.channelId,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -314,7 +252,7 @@ class ChatMessage extends StatelessWidget {
|
|||
),
|
||||
);
|
||||
} else if (shouldHighlightFirstMessage) {
|
||||
highlightColor = context.frostyColors.highlightedMessage;
|
||||
highlightColor = Colors.purple;
|
||||
messageHeaderIcon = Icon(
|
||||
Icons.auto_awesome_rounded,
|
||||
size: messageHeaderIconSize,
|
||||
|
|
@ -328,7 +266,7 @@ class ChatMessage extends StatelessWidget {
|
|||
),
|
||||
);
|
||||
} else if (shouldHighlightMessage) {
|
||||
highlightColor = context.frostyColors.highlightedMessage;
|
||||
highlightColor = const Color(0xff9146ff);
|
||||
messageHeader = Text(
|
||||
'Highlighted message',
|
||||
style: TextStyle(
|
||||
|
|
@ -339,9 +277,7 @@ class ChatMessage extends StatelessWidget {
|
|||
}
|
||||
|
||||
// If user is being mentioned in the message, highlight it red.
|
||||
if (ircMessage.mention == true) {
|
||||
highlightColor = context.colorScheme.error;
|
||||
}
|
||||
if (ircMessage.mention == true) highlightColor = Colors.red;
|
||||
|
||||
if (messageHeader != null) {
|
||||
renderMessage = Column(
|
||||
|
|
@ -416,14 +352,9 @@ class ChatMessage extends StatelessWidget {
|
|||
},
|
||||
launchExternal: chatStore.settings.launchUrlExternal,
|
||||
timestamp: chatStore.settings.timestampType,
|
||||
forceTimestamp: forceTimestamp,
|
||||
showHistoricalTimestamps:
|
||||
chatStore.settings.showHistoricalTimestamps,
|
||||
channelIdToUserTwitch:
|
||||
overrideChannelIdToUserTwitch ??
|
||||
chatStore.assetsStore.channelIdToUserTwitch,
|
||||
currentChannelId:
|
||||
overrideCurrentChannelId ?? chatStore.channelId,
|
||||
currentChannelId: chatStore.channelId,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -432,11 +363,6 @@ class ChatMessage extends StatelessWidget {
|
|||
);
|
||||
break;
|
||||
case Command.notice:
|
||||
// Use tabular figures so countdown numbers don't shift horizontally
|
||||
final noticeStyle = TextStyle(
|
||||
color: messageHeaderTextColor,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
);
|
||||
if (ircMessage.actionCallback != null &&
|
||||
ircMessage.actionLabel != null) {
|
||||
renderMessage = Text.rich(
|
||||
|
|
@ -444,7 +370,7 @@ class ChatMessage extends StatelessWidget {
|
|||
children: [
|
||||
TextSpan(
|
||||
text: ircMessage.message ?? '',
|
||||
style: noticeStyle,
|
||||
style: TextStyle(color: messageHeaderTextColor),
|
||||
),
|
||||
TextSpan(text: ' '),
|
||||
TextSpan(
|
||||
|
|
@ -464,13 +390,13 @@ class ChatMessage extends StatelessWidget {
|
|||
} else {
|
||||
renderMessage = Text.rich(
|
||||
TextSpan(text: ircMessage.message),
|
||||
style: noticeStyle,
|
||||
style: TextStyle(color: messageHeaderTextColor),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case Command.userNotice:
|
||||
if (chatStore.settings.showUserNotices) {
|
||||
highlightColor = context.frostyColors.highlightedMessage;
|
||||
highlightColor = const Color(0xff9146ff);
|
||||
Widget? messageHeaderIcon;
|
||||
Widget? messageHeader;
|
||||
|
||||
|
|
@ -512,14 +438,10 @@ class ChatMessage extends StatelessWidget {
|
|||
messageHeaderIcon = Icon(
|
||||
Icons.campaign_rounded,
|
||||
size: messageHeaderIconSize,
|
||||
color: messageHeaderTextColor,
|
||||
);
|
||||
messageHeader = Text(
|
||||
messageHeader = const Text(
|
||||
'Announcement',
|
||||
style: TextStyle(
|
||||
fontWeight: messageHeaderFontWeight,
|
||||
color: messageHeaderTextColor,
|
||||
),
|
||||
style: TextStyle(fontWeight: messageHeaderFontWeight),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -548,14 +470,9 @@ class ChatMessage extends StatelessWidget {
|
|||
badgeScale: chatStore.settings.badgeScale,
|
||||
launchExternal: chatStore.settings.launchUrlExternal,
|
||||
timestamp: chatStore.settings.timestampType,
|
||||
forceTimestamp: forceTimestamp,
|
||||
showHistoricalTimestamps:
|
||||
chatStore.settings.showHistoricalTimestamps,
|
||||
channelIdToUserTwitch:
|
||||
overrideChannelIdToUserTwitch ??
|
||||
chatStore.assetsStore.channelIdToUserTwitch,
|
||||
currentChannelId:
|
||||
overrideCurrentChannelId ?? chatStore.channelId,
|
||||
currentChannelId: chatStore.channelId,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -626,24 +543,15 @@ class ChatMessage extends StatelessWidget {
|
|||
child: dividedMessage,
|
||||
);
|
||||
|
||||
final shouldFade = chatStore.shouldFadeMessage(
|
||||
sourceChannelId: ircMessage.sourceChannelId,
|
||||
overrideCurrentChannelId: overrideCurrentChannelId,
|
||||
);
|
||||
|
||||
final fadedMessage = shouldFade
|
||||
? Opacity(opacity: 0.55, child: coloredMessage)
|
||||
: coloredMessage;
|
||||
|
||||
final finalMessage = InkWell(
|
||||
onTap: () {
|
||||
FocusScope.of(context).unfocus();
|
||||
if (_effectiveInputStore.assetsStore.showEmoteMenu) {
|
||||
_effectiveInputStore.assetsStore.showEmoteMenu = false;
|
||||
if (chatStore.assetsStore.showEmoteMenu) {
|
||||
chatStore.assetsStore.showEmoteMenu = false;
|
||||
}
|
||||
},
|
||||
onLongPress: () => onLongPressMessage(context, defaultTextStyle),
|
||||
child: fadedMessage,
|
||||
child: coloredMessage,
|
||||
);
|
||||
|
||||
return finalMessage;
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ import 'dart:io';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:frosty/models/user.dart';
|
||||
import 'package:frosty/screens/channel/chat/chat.dart';
|
||||
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
|
||||
import 'package:frosty/screens/channel/chat/widgets/add_chat_dialog.dart';
|
||||
import 'package:frosty/utils.dart';
|
||||
import 'package:frosty/widgets/frosty_dialog.dart';
|
||||
import 'package:frosty/widgets/profile_picture.dart';
|
||||
|
||||
/// Widget that displays multiple chat tabs with a tab bar.
|
||||
|
|
@ -39,6 +39,7 @@ class ChatTabs extends StatelessWidget {
|
|||
);
|
||||
|
||||
if (!added && context.mounted) {
|
||||
// If not added, it means the channel already exists (switched to it)
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Channel already open, switched to it'),
|
||||
|
|
@ -49,36 +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
|
||||
Widget build(BuildContext context) {
|
||||
return Observer(
|
||||
builder: (context) {
|
||||
final tabs = chatTabsStore.tabs;
|
||||
final activeIndex = chatTabsStore.activeTabIndex;
|
||||
final activeStore = chatTabsStore.activeChatStore;
|
||||
// Twitch native shared chat participants (excluding the active
|
||||
// channel itself, which is already shown as its own tab chip).
|
||||
final sharedChatParticipants = activeStore.isInSharedChatMode
|
||||
? activeStore.assetsStore.channelIdToUserTwitch.entries
|
||||
.where((entry) => entry.key != activeStore.channelId)
|
||||
.toList()
|
||||
: const <MapEntry<String, UserTwitch>>[];
|
||||
final showTabBar =
|
||||
chatTabsStore.showTabBar || sharedChatParticipants.isNotEmpty;
|
||||
final showMerge = tabs.where((t) => t.isActivated).length >= 2;
|
||||
final showTabBar = chatTabsStore.showTabBar;
|
||||
|
||||
// Calculate extra top padding for tab bar when visible
|
||||
final tabBarHeight = showTabBar ? 48.0 : 0.0;
|
||||
|
||||
// Get the top inset from listPadding (e.g., for AppBar in chat-only mode)
|
||||
final topInset = listPadding?.resolve(TextDirection.ltr).top ?? 0;
|
||||
|
||||
// Adjust list padding to account for tab bar
|
||||
final adjustedPadding = listPadding != null
|
||||
? listPadding!.add(EdgeInsets.only(top: tabBarHeight))
|
||||
: EdgeInsets.only(top: tabBarHeight);
|
||||
|
|
@ -88,43 +74,36 @@ class ChatTabs extends StatelessWidget {
|
|||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) return;
|
||||
|
||||
// Android back gesture: dismiss overlays before navigating away.
|
||||
// If pressing the back button on Android while the emote menu
|
||||
// is open, close it instead of going back to the streams list.
|
||||
final activeStore = chatTabsStore.activeChatStore;
|
||||
if (activeStore.assetsStore.showEmoteMenu) {
|
||||
activeStore.assetsStore.showEmoteMenu = false;
|
||||
return;
|
||||
} else {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
if (activeStore.textFieldFocusNode.hasFocus) {
|
||||
activeStore.unfocusInput();
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
// Chat content with IndexedStack to preserve state
|
||||
Positioned.fill(
|
||||
child: chatTabsStore.mergedMode
|
||||
? Chat(
|
||||
chatStore: chatTabsStore.activeChatStore,
|
||||
chatTabsStore: chatTabsStore,
|
||||
listPadding: adjustedPadding,
|
||||
onAddChat: () => _handleAddChat(context),
|
||||
)
|
||||
: IndexedStack(
|
||||
index: activeIndex,
|
||||
children: tabs.map((tabInfo) {
|
||||
if (tabInfo.chatStore == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Chat(
|
||||
key: ValueKey(tabInfo.channelId),
|
||||
chatStore: tabInfo.chatStore!,
|
||||
listPadding: adjustedPadding,
|
||||
onAddChat: () => _handleAddChat(context),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
child: IndexedStack(
|
||||
index: activeIndex,
|
||||
children: tabs.map((tabInfo) {
|
||||
// Show placeholder for non-activated tabs
|
||||
if (tabInfo.chatStore == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Chat(
|
||||
key: ValueKey(tabInfo.channelId),
|
||||
chatStore: tabInfo.chatStore!,
|
||||
listPadding: adjustedPadding,
|
||||
onAddChat: () => _handleAddChat(context),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
// Tab bar (only visible when more than 1 tab)
|
||||
if (showTabBar)
|
||||
Positioned(
|
||||
top: topInset,
|
||||
|
|
@ -132,78 +111,30 @@ class ChatTabs extends StatelessWidget {
|
|||
right: 0,
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Right-clip past the merge button so chips slide
|
||||
// under its left half then disappear.
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: showMerge ? 33 : 0,
|
||||
child: ReorderableListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: tabs.length + sharedChatParticipants.length,
|
||||
padding: EdgeInsets.only(
|
||||
left: 12,
|
||||
right: showMerge ? 32 : 12,
|
||||
),
|
||||
onReorderItem: (oldIndex, newIndex) {
|
||||
// Shared chat bubbles are trailing, fixed-order
|
||||
// items (not real tabs) — ignore any reorder
|
||||
// that touches them.
|
||||
if (oldIndex >= tabs.length ||
|
||||
newIndex >= tabs.length) {
|
||||
return;
|
||||
}
|
||||
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: ReorderableListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: tabs.length,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
HapticFeedback.lightImpact();
|
||||
chatTabsStore.reorderTab(oldIndex, newIndex);
|
||||
},
|
||||
proxyDecorator: (child, index, animation) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final tabInfo = chatTabsStore.tabs[index];
|
||||
return Padding(
|
||||
key: ValueKey(tabInfo.channelId),
|
||||
padding: EdgeInsets.only(
|
||||
right: index < tabs.length - 1 ? 4 : 0,
|
||||
),
|
||||
),
|
||||
if (showMerge)
|
||||
Positioned(
|
||||
right: 12,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Center(
|
||||
child: _buildMoreActionsMenu(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: _buildTab(context, index),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -214,329 +145,72 @@ class ChatTabs extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildMoreActionsMenu(BuildContext context) {
|
||||
final settings = chatTabsStore.activeChatStore.settings;
|
||||
|
||||
return _AnchoredPopupMenu(
|
||||
itemsBuilder: (close) => [
|
||||
Observer(
|
||||
builder: (_) => CheckboxMenuButton(
|
||||
value: chatTabsStore.mergedMode,
|
||||
onChanged: (_) {
|
||||
HapticFeedback.selectionClick();
|
||||
chatTabsStore.toggleMergedMode();
|
||||
},
|
||||
child: const Text('Merge chats'),
|
||||
Future<void> _confirmRemoveTab(
|
||||
BuildContext context,
|
||||
int index,
|
||||
String displayName,
|
||||
) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => FrostyDialog(
|
||||
title: 'Remove $displayName',
|
||||
message: "Are you sure you want to remove $displayName's chat tab?",
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
),
|
||||
Observer(
|
||||
builder: (_) => CheckboxMenuButton(
|
||||
value: settings.focusCurrentChannel,
|
||||
onChanged: (newValue) {
|
||||
HapticFeedback.selectionClick();
|
||||
settings.focusCurrentChannel = newValue ?? false;
|
||||
},
|
||||
child: const Text('Focus current channel'),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Remove'),
|
||||
),
|
||||
),
|
||||
],
|
||||
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) {
|
||||
final tabInfo = chatTabsStore.tabs[index];
|
||||
final isActive = index == chatTabsStore.activeTabIndex;
|
||||
final isSecondary = !tabInfo.isPrimary;
|
||||
final isActivated = tabInfo.isActivated;
|
||||
final displayName = getReadableName(
|
||||
tabInfo.displayName,
|
||||
tabInfo.channelLogin,
|
||||
);
|
||||
|
||||
return Observer(
|
||||
builder: (context) {
|
||||
final theme = Theme.of(context);
|
||||
final isActivated = tabInfo.isActivated;
|
||||
final hasUnread = chatTabsStore.hasUnreadMessages(index);
|
||||
final dimmed = !isActivated || !(tabInfo.chatStore?.isConnected ?? false);
|
||||
final avatar = ProfilePicture(userLogin: tabInfo.channelLogin, radius: 12);
|
||||
|
||||
InputChip buildChip({VoidCallback? onDeleted}) => InputChip(
|
||||
avatar: Badge(
|
||||
smallSize: 8,
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
isLabelVisible: hasUnread,
|
||||
child: Opacity(
|
||||
opacity: dimmed ? 0.5 : 1.0,
|
||||
child: ProfilePicture(
|
||||
userLogin: tabInfo.channelLogin,
|
||||
radius: 12,
|
||||
),
|
||||
),
|
||||
return InputChip(
|
||||
avatar: isActivated ? avatar : Opacity(opacity: 0.5, child: avatar),
|
||||
label: Text(
|
||||
displayName,
|
||||
style: isActivated
|
||||
? null
|
||||
: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.color?.withValues(alpha: 0.5),
|
||||
),
|
||||
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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
||||
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
|
||||
import 'package:frosty/screens/channel/chat/widgets/moderation_actions.dart';
|
||||
import 'package:frosty/utils.dart';
|
||||
import 'package:frosty/utils/modal_bottom_sheet.dart';
|
||||
import 'package:frosty/widgets/alert_message.dart';
|
||||
|
|
@ -15,7 +14,6 @@ class ChatUserModal extends StatefulWidget {
|
|||
final String username;
|
||||
final String displayName;
|
||||
final String userId;
|
||||
final VoidCallback? onActivateSourceTab;
|
||||
|
||||
const ChatUserModal({
|
||||
super.key,
|
||||
|
|
@ -23,7 +21,6 @@ class ChatUserModal extends StatefulWidget {
|
|||
required this.username,
|
||||
required this.displayName,
|
||||
required this.userId,
|
||||
this.onActivateSourceTab,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -64,7 +61,6 @@ class _ChatUserModalState extends State<ChatUserModal> {
|
|||
IconButton(
|
||||
tooltip: 'Reply',
|
||||
onPressed: () {
|
||||
widget.onActivateSourceTab?.call();
|
||||
widget.chatStore.textController.text =
|
||||
'@${widget.username} ';
|
||||
Navigator.pop(context);
|
||||
|
|
@ -81,13 +77,6 @@ class _ChatUserModalState extends State<ChatUserModal> {
|
|||
name: name,
|
||||
userLogin: widget.username,
|
||||
userId: widget.userId,
|
||||
// Moderator actions for this user (no messageId → no
|
||||
// Delete); renders nothing for non-moderators.
|
||||
moderationActions: ModerationActions(
|
||||
chatStore: widget.chatStore,
|
||||
targetUserId: widget.userId,
|
||||
targetName: name,
|
||||
),
|
||||
),
|
||||
),
|
||||
icon: Icon(Icons.adaptive.more_rounded),
|
||||
|
|
@ -126,7 +115,6 @@ class _ChatUserModalState extends State<ChatUserModal> {
|
|||
ircMessage: userMessages[index],
|
||||
chatStore: widget.chatStore,
|
||||
isModal: true,
|
||||
forceTimestamp: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -61,7 +61,7 @@ class ReplyThread extends StatelessWidget {
|
|||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||
child: Text(
|
||||
'$replyName: $replyBody',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue