diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 00000000..4b6fcbcc --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,5 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +@../AGENTS.md diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 00000000..0127976e --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "dart": { + "command": "dart", + "args": ["mcp-server", "--force-roots-fallback"] + }, + "next-devtools": { + "command": "node", + "args": ["web/node_modules/next-devtools-mcp/dist/index.js"] + } + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..4d4439a4 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +CLIENT_ID= +SECRET= diff --git a/.forgejo/workflows/actions-probe.yml b/.forgejo/workflows/actions-probe.yml new file mode 100644 index 00000000..a9e9414d --- /dev/null +++ b/.forgejo/workflows/actions-probe.yml @@ -0,0 +1,14 @@ +# 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" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 00000000..8e8ac8fd --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,304 @@ +# 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}" </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" diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index e82f4c98..0ce3b2a1 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1,3 @@ github: tommyxchow -custom: ["buymeacoffee.com/tommychow", "paypal.com/donate/?business=NF33JDG6KBU6W"] +buy_me_a_coffee: tommychow +custom: https://www.tesla.com/referral/tommy414363 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..9d449f02 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,33 @@ +version: 2 +updates: + # Keep GitHub Actions up to date + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "ci" + groups: + actions: + patterns: + - "*" + + # Keep Ruby gems (fastlane) up to date + - package-ecosystem: "bundler" + directory: "/android" + schedule: + interval: "monthly" + + - package-ecosystem: "bundler" + directory: "/ios" + schedule: + interval: "monthly" + + # Keep Dart/Flutter packages up to date + - package-ecosystem: "pub" + directory: "/" + schedule: + interval: "weekly" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54414437..d6c5c96b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,18 +3,57 @@ on: push: branches: [main] jobs: - test: - name: Check and test on ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + # 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 steps: - - uses: actions/checkout@v3 + - 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 diff --git a/.github/workflows/gh-release.yml b/.github/workflows/gh-release.yml index 95816783..a0d23fd7 100644 --- a/.github/workflows/gh-release.yml +++ b/.github/workflows/gh-release.yml @@ -9,6 +9,7 @@ jobs: android: name: Draft release and attach APK runs-on: ubuntu-latest + environment: release-android env: UPLOAD_KEYSTORE: ${{ secrets.UPLOAD_KEYSTORE }} STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }} @@ -18,19 +19,20 @@ jobs: SECRET: ${{ secrets.SECRET }} steps: - name: Checkout Frosty repo - uses: actions/checkout@v3 + uses: actions/checkout@v6 # Setup Java so that we can build the APK. - name: Set up Java - uses: actions/setup-java@v3 + uses: actions/setup-java@v5 with: - distribution: zulu - java-version: 11 + 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. @@ -50,14 +52,54 @@ 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<> $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@v1 + uses: softprops/action-gh-release@v3 with: draft: true - generate_release_notes: true + generate_release_notes: ${{ steps.release_notes.outputs.found != 'true' }} + body: ${{ steps.release_notes.outputs.found == 'true' && steps.release_notes.outputs.body || '' }} files: frosty-${{ github.ref_name }}.apk - - - diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index cdd59011..1c3e9897 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -7,11 +7,37 @@ jobs: name: Check and test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - 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' diff --git a/.github/workflows/release-dryrun.yml b/.github/workflows/release-dryrun.yml new file mode 100644 index 00000000..0b905936 --- /dev/null +++ b/.github/workflows/release-dryrun.yml @@ -0,0 +1,126 @@ +# 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 diff --git a/.github/workflows/store-release.yml b/.github/workflows/store-release.yml index f6c0f61c..53365a9e 100644 --- a/.github/workflows/store-release.yml +++ b/.github/workflows/store-release.yml @@ -13,10 +13,13 @@ env: 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: Build and release Frosty on Android runs-on: ubuntu-latest + environment: release-android env: UPLOAD_KEYSTORE: ${{ secrets.UPLOAD_KEYSTORE }} STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }} @@ -26,32 +29,33 @@ jobs: steps: - name: Checkout Frosty repo - uses: actions/checkout@v3 - + 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.1 + ruby-version: 3.3 bundler-cache: true - + # Setup Java so that we can build the appbundle. - name: Set up Java - uses: actions/setup-java@v3 + uses: actions/setup-java@v5 with: - distribution: zulu - java-version: 11 - + 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: | @@ -59,17 +63,18 @@ jobs: 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. - name: Build the Android AAB run: flutter build appbundle --dart-define CLIENT_ID=$CLIENT_ID --dart-define SECRET=$SECRET - + # Deploy to the play store with fastlane. - name: Deploy with fastlane run: cd android && bundle exec fastlane deploy ios: name: Build and release Frosty on iOS 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 }} @@ -78,19 +83,25 @@ jobs: MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} steps: - name: Checkout Frosty repo - uses: actions/checkout@v3 - + 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.1 + 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). diff --git a/.gitignore b/.gitignore index 8c6cab51..dfee3eb8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,66 +1,73 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.buildlog/ -.history -.svn/ -migrate_working_dir/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# 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/ - -# Flutter/Dart/Pub related -**/doc/api/ -**/ios/Flutter/.last_build_id -.dart_tool/ -.flutter-plugins -.flutter-plugins-dependencies -.packages -.pub-cache/ -.pub/ -/build/ - -# Web related -lib/generated_plugin_registrant.dart - -# Symbolication related -app.*.symbols - -# Obfuscation related -app.*.map.json - -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release - -# Folder containing random testing files -random/ - -# fastlane specific -**/fastlane/report.xml - -# deliver temporary files -**/fastlane/Preview.html - -# snapshot generated screenshots -**/fastlane/screenshots -**/fastlane/metadata/android/en-US/images - -# scan temporary files -**/fastlane/test_output - -# potentially sensitive review information -**/fastlane/metadata/review_information \ No newline at end of file +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# 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/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/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/ + +# fastlane specific +**/fastlane/report.xml + +# deliver temporary files +**/fastlane/Preview.html + +# snapshot generated screenshots +**/fastlane/screenshots +**/fastlane/metadata/android/en-US/images + +# scan temporary files +**/fastlane/test_output + +# potentially sensitive review information +**/fastlane/metadata/review_information + +# secrets +.env + +# ai +.claude/settings.local.json \ No newline at end of file diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..0127976e --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "dart": { + "command": "dart", + "args": ["mcp-server", "--force-roots-fallback"] + }, + "next-devtools": { + "command": "node", + "args": ["web/node_modules/next-devtools-mcp/dist/index.js"] + } + } +} diff --git a/.metadata b/.metadata index 56bfc2c4..f991a3ab 100644 --- a/.metadata +++ b/.metadata @@ -1,10 +1,33 @@ # This file tracks properties of this Flutter project. # Used by Flutter tool to assess capabilities and perform upgrades etc. # -# This file should be version controlled and should not be manually edited. +# This file should be version controlled. version: - revision: f4abaa0735eba4dfd8f33f73363911d63931fe03 + revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 channel: stable project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 + base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 + - platform: android + create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 + base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 + - platform: ios + create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 + base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..dad4cfce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,25 @@ +{ + "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"] + } + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..4495eb73 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,46 @@ +# 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. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..07a0fe02 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,61 @@ +# 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`. diff --git a/README.md b/README.md index 09e1e8ad..834bbc44 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,6 @@ -

- - The Frosty rounded logo. -

Frosty

- -

+![Showcase of the Frosty app with screenshots in a masonry grid](https://github.com/tommyxchow/frosty/assets/54859075/09178dcc-2fd2-4618-8076-502719159424) -

- A mobile - Twitch - client for iOS and Android with - 7TV, BTTV, - and - FFZ - support. Built with - Flutter. -

- -

+

Badge showing the CI status.

-

+## Download + +

-

- - Buy Me A Coffee badge. - -

+## Why -

- iOS screenshot of the Followed Streams section. - iOS screenshot of xQc's channel with the stream and chat. - iOS screenshot of the search section with results from the query "pokelaw". -

- -## Motivation - -The official Twitch mobile app doesn't support emotes from [7TV](https://chrome.google.com/webstore/detail/7tv/ammjkodgmmoknidbanneddgankgfejfh), [BetterTTV (BTTV)](https://chrome.google.com/webstore/detail/betterttv/ajopnjidmegmdimjlfnijceegpefgped), and [FrankerFaceZ (FFZ)](https://chrome.google.com/webstore/detail/frankerfacez/fadndhdgpmmaapbmfcknlfgcflmmmieb) — third-party services and extensions for Twitch used by millions of viewers and many top channels. This results in a poor mobile chat experience since only emote codes render rather than their image or GIF counterpart. +The official Twitch mobile app doesn't support emotes from [7TV](https://chrome.google.com/webstore/detail/7tv/ammjkodgmmoknidbanneddgankgfejfh), [BetterTTV (BTTV)](https://chrome.google.com/webstore/detail/betterttv/ajopnjidmegmdimjlfnijceegpefgped), and [FrankerFaceZ (FFZ)](https://chrome.google.com/webstore/detail/frankerfacez/fadndhdgpmmaapbmfcknlfgcflmmmieb) — third-party extensions for Twitch used by millions. As a result, only emote text names are rendered rather than their actual image or GIF, making the chat unreadable in many channels. ## Features @@ -129,45 +79,35 @@ For a more detailed overview, visit [frostyapp.io](https://www.frostyapp.io/). ## Development setup -1. Go to the [Twitch dev console](https://dev.twitch.tv/login) and register a new application to retrieve a **Client ID** and **Client Secret**. +1. [Install Flutter](https://docs.flutter.dev/get-started/install). -2. Clone the repo to a directory (e.g., `git clone https://github.com/tommyxchow/frosty.git`). +2. Clone this repo (e.g., `git clone https://github.com/tommyxchow/frosty.git`). -3. Navigate to `lib/constants.dart` and replace the `clientId` and `secret` constants with your **client ID** and **client secret** from step 1 (if using VSCode, use `--dart-define` to [define them as environment variables](https://dartcode.org/docs/using-dart-define-in-flutter/)). +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. Run `flutter pub get` to fetch all the dependencies. +4. Copy `.env.example` to `.env` and fill in your credentials: -5. Choose an emulator or device and run the app! + ```bash + cp .env.example .env + ``` -> **Note** -> +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. + +> [!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. -> **Warning** -> -> I built Frosty while learning Flutter, so some of the code may not be optimal. I'm working on refactoring, documenting, and cleaning up various parts as much as I can. - -## Feature requests and issues - -If you have a feature request, found a bug, or have a general issue, you can submit it [here](https://github.com/tommyxchow/frosty/issues/new/choose) on the issues tab. Doing so makes it easier for me to keep track of them and makes it publicly visible for others to review. - -> **Note** -> -> I work on Frosty in my free time and take occasional breaks, so I may not respond immediately. - ## Donate -Downloading Frosty and leaving a review or starring this repository is more than enough to show support. +If you appreciate my work and would like to donate/tip, you can through: -If you're feeling generous and would like to support me with a donation, you can do so through the following: - -- [Buy Me a Coffee](https://www.buymeacoffee.com/tommychow) - [GitHub Sponsors](https://github.com/sponsors/tommyxchow) -- [PayPal](https://www.paypal.com/donate/?business=NF33JDG6KBU6W) -- BTC: bc1qzpth6gc3vum764lat6a8ul7cmekwles58070a6 -- ETH: 0x317b5930fc2898884f711016dCae79d24910888E +- [Buy Me a Coffee](https://www.buymeacoffee.com/tommychow) -If you decide to support me monetarily, it is extremely appreciated and further motivates me to continue improving and maintaining Frosty ❤️. +Otherwise, downloading Frosty, leaving a review, or starring this repository is more than enough to show support. Thank you! ## License diff --git a/analysis_options.yaml b/analysis_options.yaml index e06ccaf3..3b60af70 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,39 +1,46 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. - -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml - -analyzer: - exclude: [lib/generated_plugin_registrant.dart] - -linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at - # https://dart-lang.github.io/linter/lints/index.html. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. - rules: - - directives_ordering - - prefer_single_quotes - - always_use_package_imports - - avoid_void_async - - prefer_final_locals - - prefer_final_in_for_each - - avoid_redundant_argument_values - - unnecessary_parenthesis - - always_declare_return_types - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml +analyzer: + exclude: + - lib/**.g.dart + - .dart_tool/** + - build/** + - ios/** + - android/** + - web/** + - windows/** + - macos/** + - linux/** + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + - directives_ordering + - prefer_single_quotes + - always_use_package_imports + - avoid_void_async + - prefer_final_locals + - prefer_final_in_for_each + - avoid_redundant_argument_values + - unnecessary_parenthesis + - always_declare_return_types + - require_trailing_commas +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore index 6f568019..be3943c9 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -5,9 +5,10 @@ gradle-wrapper.jar /gradlew.bat /local.properties GeneratedPluginRegistrant.java +.cxx/ # Remember to never publicly share your keystore. -# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +# See https://flutter.dev/to/reference-keystore key.properties **/*.keystore **/*.jks diff --git a/android/Gemfile.lock b/android/Gemfile.lock index 7cbb618c..41b69f5c 100644 --- a/android/Gemfile.lock +++ b/android/Gemfile.lock @@ -1,43 +1,49 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.6) - rexml - addressable (2.8.4) - public_suffix (>= 2.0.2, < 6.0) - artifactory (3.0.15) + CFPropertyList (3.0.8) + abbrev (0.1.2) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + artifactory (3.0.17) atomos (0.1.3) - aws-eventstream (1.2.0) - aws-partitions (1.757.0) - aws-sdk-core (3.171.0) - aws-eventstream (~> 1, >= 1.0.2) - aws-partitions (~> 1, >= 1.651.0) - aws-sigv4 (~> 1.5) + aws-eventstream (1.4.0) + aws-partitions (1.1260.0) + aws-sdk-core (3.252.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.63.0) - aws-sdk-core (~> 3, >= 3.165.0) - aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.121.0) - aws-sdk-core (~> 3, >= 3.165.0) + logger + aws-sdk-kms (1.129.0) + aws-sdk-core (~> 3, >= 3.248.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.225.1) + aws-sdk-core (~> 3, >= 3.248.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.4) - aws-sigv4 (1.5.2) + 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) 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.6.4) + digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) - domain_name (0.5.20190701) - unf (>= 0.0.5, < 1.0.0) + domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) - excon (0.99.0) - faraday (1.10.3) + excon (0.112.0) + faraday (1.10.5) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) @@ -49,32 +55,36 @@ GEM faraday-rack (~> 1.0) faraday-retry (~> 1.0) ruby2_keywords (>= 0.0.4) - faraday-cookie_jar (0.0.7) + faraday-cookie_jar (0.0.8) 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.0) + faraday-em_synchrony (1.0.1) faraday-excon (1.1.0) faraday-httpclient (1.0.1) - faraday-multipart (1.0.4) - multipart-post (~> 2) - faraday-net_http (1.0.1) + faraday-multipart (1.2.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.3) - faraday_middleware (1.2.0) + faraday-retry (1.0.4) + faraday_middleware (1.2.1) faraday (~> 1.0) - fastimage (2.2.6) - fastlane (2.212.2) - CFPropertyList (>= 2.3, < 4.0.0) + fastimage (2.4.1) + fastlane (2.234.0) + CFPropertyList (>= 2.3, < 5.0.0) + abbrev (~> 0.1) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) - aws-sdk-s3 (~> 1.0) + aws-sdk-s3 (~> 1.197) babosa (>= 1.0.3, < 2.0.0) - bundler (>= 1.12.0, < 3.0.0) - colored + base64 (~> 0.2) + benchmark (>= 0.1.0) + bundler (>= 1.17.3, < 5.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) @@ -82,136 +92,147 @@ GEM faraday-cookie_jar (~> 0.0.6) faraday_middleware (~> 1.0) fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.1.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-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) + multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3) naturally (~> 2.2) - optparse (~> 0.1.1) + 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.3) + security (= 0.1.5) simctl (~> 1.6.3) terminal-notifier (>= 2.0.0, < 3.0.0) - terminal-table (>= 1.4.5, < 2.0.0) + terminal-table (~> 3) tty-screen (>= 0.6.3, < 1.0.0) tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) xcodeproj (>= 1.13.0, < 2.0.0) - xcpretty (~> 0.3.0) - xcpretty-travis-formatter (>= 0.0.3) + xcpretty (~> 0.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-sirp (1.1.0) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.41.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-core (0.11.0) + google-apis-androidpublisher_v3 (0.102.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (0.18.0) addressable (~> 2.5, >= 2.5.1) - googleauth (>= 0.16.2, < 2.a) - httpclient (>= 2.8.1, < 3.a) + googleauth (~> 1.9) + httpclient (>= 2.8.3, < 3.a) mini_mime (~> 1.0) + mutex_m representable (~> 3.0) retriable (>= 2.0, < 4.a) - rexml - webrick - 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.19.0) - google-apis-core (>= 0.9.0, < 2.a) - google-cloud-core (1.6.0) - google-cloud-env (~> 1.0) + 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) + google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) - google-cloud-env (1.6.0) - faraday (>= 0.17.3, < 3.0) - google-cloud-errors (1.3.1) - google-cloud-storage (1.44.0) + google-cloud-env (2.1.1) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.6.0) + google-cloud-storage (1.61.0) addressable (~> 2.8) digest-crc (~> 0.4) - google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.19.0) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) google-cloud-core (~> 1.6) - googleauth (>= 0.16.2, < 2.a) + googleauth (~> 1.9) mini_mime (~> 1.0) - googleauth (1.5.2) - faraday (>= 0.17.3, < 3.a) + googleauth (1.11.2) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.1) jwt (>= 1.4, < 3.0) - memoist (~> 0.16) multi_json (~> 1.11) os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) highline (2.0.3) - http-cookie (1.0.5) + http-cookie (1.0.8) domain_name (~> 0.5) - httpclient (2.8.3) + httpclient (2.9.0) + mutex_m jmespath (1.6.2) - json (2.6.3) - jwt (2.7.0) - memoist (0.16.2) - mini_magick (4.12.0) - mini_mime (1.1.2) - multi_json (1.15.0) - multipart-post (2.0.0) - nanaimo (0.3.0) - naturally (2.2.1) - optparse (0.1.1) + json (2.19.9) + jwt (2.10.3) + base64 + logger (1.7.0) + mini_magick (4.13.2) + mini_mime (1.1.5) + multi_json (1.21.1) + 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) os (1.1.4) - plist (3.7.0) - public_suffix (5.0.1) - rake (13.0.6) + ostruct (0.6.3) + plist (3.7.2) + public_suffix (7.0.5) + rake (13.4.2) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) - retriable (3.1.2) - rexml (3.2.5) - rouge (2.0.7) + retriable (3.8.0) + rexml (3.4.4) + rouge (3.28.0) ruby2_keywords (0.0.5) - rubyzip (2.3.2) - security (0.1.3) - signet (0.17.0) + rubyzip (2.4.1) + security (0.1.5) + signet (0.22.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) - jwt (>= 1.5, < 3.0) - multi_json (~> 1.10) + jwt (>= 1.5, < 4.0) simctl (1.6.10) CFPropertyList naturally terminal-notifier (2.0.0) - terminal-table (1.8.0) - unicode-display_width (~> 1.1, >= 1.1.1) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) trailblazer-option (0.1.2) tty-cursor (0.7.1) - tty-screen (0.8.1) + tty-screen (0.8.2) tty-spinner (0.9.3) tty-cursor (~> 0.7) uber (0.1.0) - unf (0.1.4) - unf_ext - unf_ext (0.0.8.2) - unicode-display_width (1.8.0) - webrick (1.8.1) + unicode-display_width (2.6.0) word_wrap (1.0.0) - xcodeproj (1.22.0) + xcodeproj (1.27.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) - nanaimo (~> 0.3.0) - rexml (~> 3.2.4) - xcpretty (0.3.0) - rouge (~> 2.0.7) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.1) + rouge (~> 3.28.0) xcpretty-travis-formatter (1.0.1) xcpretty (~> 0.2, >= 0.0.7) PLATFORMS arm64-darwin-21 universal-darwin-22 + universal-darwin-23 + universal-darwin-24 x64-unknown + x86_64-darwin-20 x86_64-darwin-21 x86_64-linux diff --git a/android/app/build.gradle b/android/app/build.gradle deleted file mode 100644 index 384f0f94..00000000 --- a/android/app/build.gradle +++ /dev/null @@ -1,84 +0,0 @@ -def localProperties = new Properties() -def localPropertiesFile = rootProject.file('local.properties') -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader('UTF-8') { reader -> - localProperties.load(reader) - } -} - -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' -} - -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' -} - -apply plugin: 'com.android.application' -// START: FlutterFire Configuration -apply plugin: 'com.google.gms.google-services' -apply plugin: 'com.google.firebase.firebase-perf' -apply plugin: 'com.google.firebase.crashlytics' -// END: FlutterFire Configuration -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - -def keystoreProperties = new Properties() -def keystorePropertiesFile = rootProject.file('key.properties') -if (keystorePropertiesFile.exists()) { - keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) -} - -android { - compileSdkVersion 33 - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = '1.8' - } - - sourceSets { - main.java.srcDirs += 'src/main/kotlin' - } - - defaultConfig { - applicationId "com.tommychow.frosty" - minSdkVersion 26 - targetSdkVersion flutter.targetSdkVersion - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName - } - - 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 '../..' -} - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" -} diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 00000000..998334f3 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,70 @@ +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 = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index a0519702..399f6981 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -1,6 +1,6 @@ - - diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b63d5c93..4beaec33 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,46 +1,67 @@ - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/tommychow/frosty/CookieExtractorPlugin.kt b/android/app/src/main/kotlin/com/tommychow/frosty/CookieExtractorPlugin.kt new file mode 100644 index 00000000..055101d0 --- /dev/null +++ b/android/app/src/main/kotlin/com/tommychow/frosty/CookieExtractorPlugin.kt @@ -0,0 +1,32 @@ +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) + } +} diff --git a/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt b/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt index a7791025..30e59dc7 100644 --- a/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt +++ b/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt @@ -1,6 +1,11 @@ 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()) + } } diff --git a/android/app/src/main/res/drawable-hdpi/splash.png b/android/app/src/main/res/drawable-hdpi/splash.png index 78edba3b..7dd78fca 100644 Binary files a/android/app/src/main/res/drawable-hdpi/splash.png and b/android/app/src/main/res/drawable-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-mdpi/splash.png b/android/app/src/main/res/drawable-mdpi/splash.png index 65e45c48..0c07f1fd 100644 Binary files a/android/app/src/main/res/drawable-mdpi/splash.png and b/android/app/src/main/res/drawable-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/splash.png b/android/app/src/main/res/drawable-xhdpi/splash.png index b45507c1..83084f78 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/splash.png and b/android/app/src/main/res/drawable-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splash.png b/android/app/src/main/res/drawable-xxhdpi/splash.png index d56cc8fd..604f87bf 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/splash.png and b/android/app/src/main/res/drawable-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml index a9464b29..75578815 100644 --- a/android/app/src/main/res/values-night-v31/styles.xml +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -2,9 +2,10 @@ diff --git a/android/build.gradle b/android/build.gradle deleted file mode 100644 index 96f0e2ca..00000000 --- a/android/build.gradle +++ /dev/null @@ -1,36 +0,0 @@ -buildscript { - ext.kotlin_version = '1.6.10' - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:7.1.2' - // START: FlutterFire Configuration - classpath 'com.google.gms:google-services:4.3.10' - classpath 'com.google.firebase:perf-plugin:1.4.1' - classpath 'com.google.firebase:firebase-crashlytics-gradle:2.8.1' - // END: FlutterFire Configuration - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - -allprojects { - repositories { - google() - mavenCentral() - } -} - -rootProject.buildDir = '../build' -subprojects { - project.buildDir = "${rootProject.buildDir}/${project.name}" -} -subprojects { - project.evaluationDependsOn(':app') -} - -task clean(type: Delete) { - delete rootProject.buildDir -} diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 00000000..b477a6e8 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,37 @@ +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("androidComponents") { + finalizeDsl { it.compileSdk = 36 } + } + } +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/fastlane/Fastfile b/android/fastlane/Fastfile index 0390c22d..19e69c65 100644 --- a/android/fastlane/Fastfile +++ b/android/fastlane/Fastfile @@ -17,11 +17,14 @@ default_platform(:android) platform :android do desc "Deploy a new version to the Google Play" - lane :deploy do - # Upload to the play store ussing the provided credentials and AAB from flutter build. + 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. upload_to_play_store( json_key_data: Base64.decode64(ENV["PLAY_STORE_CREDENTIALS"]), - aab: "../build/app/outputs/bundle/release/app-release.aab" + aab: "../build/app/outputs/bundle/release/app-release.aab", + validate_only: options[:dry_run] || false ) end end diff --git a/android/fastlane/metadata/android/en-US/changelogs/38.txt b/android/fastlane/metadata/android/en-US/changelogs/38.txt new file mode 100644 index 00000000..0d4079f9 --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/38.txt @@ -0,0 +1 @@ +General improvements and bug fixes. \ No newline at end of file diff --git a/android/fastlane/metadata/android/en-US/changelogs/39.txt b/android/fastlane/metadata/android/en-US/changelogs/39.txt new file mode 100644 index 00000000..d8fe624c --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/39.txt @@ -0,0 +1,2 @@ +Bug Fixes +• Fixed emote/mention autocomplete not updating \ No newline at end of file diff --git a/android/fastlane/metadata/android/en-US/changelogs/40.txt b/android/fastlane/metadata/android/en-US/changelogs/40.txt new file mode 100644 index 00000000..8723ecf9 --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/40.txt @@ -0,0 +1,2 @@ +Bug Fixes +• Fixed audio playing in chat-only mode \ No newline at end of file diff --git a/android/fastlane/metadata/android/en-US/changelogs/41.txt b/android/fastlane/metadata/android/en-US/changelogs/41.txt new file mode 100644 index 00000000..d744e43f --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/41.txt @@ -0,0 +1,3 @@ +This major update contains Material 3/You redesigns, UI/UX improvements, reply threads, bug fixes, and more! + +See the full release notes on GitHub: https://github.com/tommyxchow/frosty/releases diff --git a/android/fastlane/metadata/android/en-US/changelogs/42.txt b/android/fastlane/metadata/android/en-US/changelogs/42.txt new file mode 100644 index 00000000..b3f420ba --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/42.txt @@ -0,0 +1,2 @@ +Bug Fixes +• Fixed missing 7TV emotes \ No newline at end of file diff --git a/android/fastlane/metadata/android/en-US/changelogs/43.txt b/android/fastlane/metadata/android/en-US/changelogs/43.txt new file mode 100644 index 00000000..5147e758 --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/43.txt @@ -0,0 +1,3 @@ +Bug Fixes +• Fixed 7TV emotes not using custom names +• Fixed 7TV zero-width emotes not working \ No newline at end of file diff --git a/android/fastlane/metadata/android/en-US/changelogs/44.txt b/android/fastlane/metadata/android/en-US/changelogs/44.txt new file mode 100644 index 00000000..1aab3634 --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/44.txt @@ -0,0 +1,3 @@ +Bug Fixes +• Fixed emote "Copy name" button copying the full name with parentheses +• Fixed multiline message clipping in chat text field \ No newline at end of file diff --git a/android/fastlane/metadata/android/en-US/changelogs/45.txt b/android/fastlane/metadata/android/en-US/changelogs/45.txt new file mode 100644 index 00000000..9737501d --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/45.txt @@ -0,0 +1,5 @@ +New Features +• Added support for replying to specific messages in chat (by @Artiu on GitHub) + +Bug Fixes +• Fixed recent emotes not clearing \ No newline at end of file diff --git a/android/fastlane/metadata/android/en-US/changelogs/46.txt b/android/fastlane/metadata/android/en-US/changelogs/46.txt new file mode 100644 index 00000000..5b288f4d --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/46.txt @@ -0,0 +1,3 @@ +This update contains support for stream quality options (by @Artiu on GitHub), support for live 7TV emote set updates in chat, updated designs for chat message alerts and highlights, and more improvements and bug fixes. + +See the full release notes on GitHub: https://github.com/tommyxchow/frosty/releases \ No newline at end of file diff --git a/android/fastlane/metadata/android/en-US/changelogs/47.txt b/android/fastlane/metadata/android/en-US/changelogs/47.txt new file mode 100644 index 00000000..7d2b2ef7 --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/47.txt @@ -0,0 +1,3 @@ +Bug Fixes +• Fixed own display name not showing in sent chat messages +• Fixed rare case where 7TV emotes don't show in chat \ No newline at end of file diff --git a/android/fastlane/metadata/android/en-US/changelogs/48.txt b/android/fastlane/metadata/android/en-US/changelogs/48.txt new file mode 100644 index 00000000..0c61db05 --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/48.txt @@ -0,0 +1,10 @@ +New Features +• Added the ability to tap on user mentions in chat (by @Artiu on GitHub) +• Added stream latency details to the custom overlay in landscape mode (by @Artiu on GitHub) + +Improvements +• Improved how the default Twitch overlay is hidden when the custom overlay is enabled (by @Artiu on GitHub) +• Links in chat are now also underlined + +Bug Fixes +• Fixed 7TV emote set updates not respecting the chat delay setting \ No newline at end of file diff --git a/android/fastlane/metadata/android/en-US/changelogs/49.txt b/android/fastlane/metadata/android/en-US/changelogs/49.txt new file mode 100644 index 00000000..2b188d59 --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/49.txt @@ -0,0 +1,4 @@ +Bug Fixes +• Fixed stream quality options not showing (by @Shingyx on GitHub) +• Fixed stream latency not showing (by @Shingyx on GitHub) +• Fixed stream latency icon color on light mode \ No newline at end of file diff --git a/android/fastlane/metadata/android/en-US/changelogs/50.txt b/android/fastlane/metadata/android/en-US/changelogs/50.txt new file mode 100644 index 00000000..5f520a01 --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/50.txt @@ -0,0 +1 @@ +This update contains a variety of new features including a quick toggle to enter or exit chat-only mode, the ability to zoom stream thumbnails and emotes or badges, an option to show historical recent messages in chat, and other improvements and bug fixes. diff --git a/android/fastlane/metadata/android/en-US/changelogs/51.txt b/android/fastlane/metadata/android/en-US/changelogs/51.txt new file mode 100644 index 00000000..13b41b31 --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/51.txt @@ -0,0 +1,9 @@ +New Features +• Added an option to un-darken historical recent messages in chat + +Improvements +• Moved exact matches of searched categories to the top (by @Shingyx on GitHub) + +Bug Fixes +• Fixed extraneous notices when toggling chat-only mode +• Fixed cookie banner blocking login and added backup workaround instructions in the login screen toolbar (instructions by @UmmonTeal on GitHub) \ No newline at end of file diff --git a/android/fastlane/metadata/android/en-US/changelogs/52.txt b/android/fastlane/metadata/android/en-US/changelogs/52.txt new file mode 100644 index 00000000..cddfc2c5 --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/52.txt @@ -0,0 +1,10 @@ +New Features +• Added a theme accent color picker in general settings +• Added the ability to pin streams in the "Following" tab by long-pressing +• Added a "My channel" shortcut in profile settings + +Improvements +• Refreshed and updated the overall design to utilize the accent color + +Bug Fixes +• Fixed certain historical recent messages not rendering properly \ No newline at end of file diff --git a/android/fastlane/metadata/android/en-US/changelogs/53.txt b/android/fastlane/metadata/android/en-US/changelogs/53.txt new file mode 100644 index 00000000..bab727c4 --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/53.txt @@ -0,0 +1,10 @@ +New Features +- Added the ability to mute keywords from chat in chat settings (by @zeykafx on GitHub) + +Improvements +- Improved initial playback stability (by @Shingyx on GitHub) +- Content warning is now accepted automatically (by @joaopjrn on GitHub) +- Updated Inter font to v4.1 + +Bug Fixes +- Fixed "Pinned" header typo \ No newline at end of file diff --git a/android/fastlane/metadata/android/en-US/changelogs/54.txt b/android/fastlane/metadata/android/en-US/changelogs/54.txt new file mode 100644 index 00000000..41b4cd10 --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/54.txt @@ -0,0 +1,2 @@ +Bug Fixes +- Further improve initial playback stability (by @Shingyx on GitHub) \ No newline at end of file diff --git a/android/fastlane/metadata/android/en-US/changelogs/55.txt b/android/fastlane/metadata/android/en-US/changelogs/55.txt new file mode 100644 index 00000000..b7cc90ed --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/55.txt @@ -0,0 +1,8 @@ +New Features +- Added initial support for shared chat sessions + +Improvements +- Changed historical recent message indicator to chat message badge icon + +Bug Fixes +- Fixed video causing random crashes on some devices \ No newline at end of file diff --git a/android/fastlane/metadata/android/en-US/changelogs/56.txt b/android/fastlane/metadata/android/en-US/changelogs/56.txt new file mode 100644 index 00000000..b03b2faa --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/56.txt @@ -0,0 +1,6 @@ +New Features +- Added new "Use enhanced rendering" option in video settings + +Bug Fixes +- Fixed incorrect badge order and spacing for FFZ bots +- Fixed missing emotes on channels that don't have 7TV enabled \ No newline at end of file diff --git a/android/fastlane/metadata/android/en-US/changelogs/default.txt b/android/fastlane/metadata/android/en-US/changelogs/default.txt new file mode 100644 index 00000000..37b5ab1f --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/default.txt @@ -0,0 +1 @@ +See full release notes at https://github.com/tommyxchow/frosty/releases \ No newline at end of file diff --git a/android/fastlane/metadata/android/en-US/short_description.txt b/android/fastlane/metadata/android/en-US/short_description.txt index e633a6b1..cf35ecf1 100644 --- a/android/fastlane/metadata/android/en-US/short_description.txt +++ b/android/fastlane/metadata/android/en-US/short_description.txt @@ -1 +1 @@ -Watch and chat on Twitch with your favorite emotes from 7TV, BTTV, and FFZ! \ No newline at end of file +Watch live streams and see community emotes in chat. diff --git a/android/fastlane/metadata/android/en-US/title.txt b/android/fastlane/metadata/android/en-US/title.txt index 902e9c36..445c1415 100644 --- a/android/fastlane/metadata/android/en-US/title.txt +++ b/android/fastlane/metadata/android/en-US/title.txt @@ -1 +1 @@ -Frosty for Twitch \ No newline at end of file +Frosty diff --git a/android/gradle.properties b/android/gradle.properties index 94adc3a3..6fdfce35 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,8 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true -android.enableJetifier=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 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index cc5527d7..bcef617e 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Fri Jun 23 08:50:38 CEST 2017 -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip +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 diff --git a/android/settings.gradle b/android/settings.gradle deleted file mode 100644 index 44e62bcf..00000000 --- a/android/settings.gradle +++ /dev/null @@ -1,11 +0,0 @@ -include ':app' - -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() - -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } - -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 00000000..f68c56f0 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,28 @@ +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") diff --git a/assets/fonts/Inter-Black.ttf b/assets/fonts/Inter-Black.ttf index 37742709..69762d79 100644 Binary files a/assets/fonts/Inter-Black.ttf and b/assets/fonts/Inter-Black.ttf differ diff --git a/assets/fonts/Inter-BlackItalic.ttf b/assets/fonts/Inter-BlackItalic.ttf new file mode 100644 index 00000000..c15c3ba1 Binary files /dev/null and b/assets/fonts/Inter-BlackItalic.ttf differ diff --git a/assets/fonts/Inter-Bold.ttf b/assets/fonts/Inter-Bold.ttf index 76a215cc..9fb9b751 100644 Binary files a/assets/fonts/Inter-Bold.ttf and b/assets/fonts/Inter-Bold.ttf differ diff --git a/assets/fonts/Inter-BoldItalic.ttf b/assets/fonts/Inter-BoldItalic.ttf new file mode 100644 index 00000000..8d050001 Binary files /dev/null and b/assets/fonts/Inter-BoldItalic.ttf differ diff --git a/assets/fonts/Inter-ExtraBold.ttf b/assets/fonts/Inter-ExtraBold.ttf index 7ae3a4e8..0fdf1e05 100644 Binary files a/assets/fonts/Inter-ExtraBold.ttf and b/assets/fonts/Inter-ExtraBold.ttf differ diff --git a/assets/fonts/Inter-ExtraBoldItalic.ttf b/assets/fonts/Inter-ExtraBoldItalic.ttf new file mode 100644 index 00000000..b1e2eae3 Binary files /dev/null and b/assets/fonts/Inter-ExtraBoldItalic.ttf differ diff --git a/assets/fonts/Inter-ExtraLight.ttf b/assets/fonts/Inter-ExtraLight.ttf index 131929e5..a758a018 100644 Binary files a/assets/fonts/Inter-ExtraLight.ttf and b/assets/fonts/Inter-ExtraLight.ttf differ diff --git a/assets/fonts/Inter-ExtraLightItalic.ttf b/assets/fonts/Inter-ExtraLightItalic.ttf new file mode 100644 index 00000000..647ea6ae Binary files /dev/null and b/assets/fonts/Inter-ExtraLightItalic.ttf differ diff --git a/assets/fonts/Inter-Italic.ttf b/assets/fonts/Inter-Italic.ttf new file mode 100644 index 00000000..e7ed211d Binary files /dev/null and b/assets/fonts/Inter-Italic.ttf differ diff --git a/assets/fonts/Inter-Light.ttf b/assets/fonts/Inter-Light.ttf index b9c460dc..2e073931 100644 Binary files a/assets/fonts/Inter-Light.ttf and b/assets/fonts/Inter-Light.ttf differ diff --git a/assets/fonts/Inter-LightItalic.ttf b/assets/fonts/Inter-LightItalic.ttf new file mode 100644 index 00000000..e711ef8c Binary files /dev/null and b/assets/fonts/Inter-LightItalic.ttf differ diff --git a/assets/fonts/Inter-Medium.ttf b/assets/fonts/Inter-Medium.ttf index 06ae9e9c..458cd060 100644 Binary files a/assets/fonts/Inter-Medium.ttf and b/assets/fonts/Inter-Medium.ttf differ diff --git a/assets/fonts/Inter-MediumItalic.ttf b/assets/fonts/Inter-MediumItalic.ttf new file mode 100644 index 00000000..22b898df Binary files /dev/null and b/assets/fonts/Inter-MediumItalic.ttf differ diff --git a/assets/fonts/Inter-Regular.ttf b/assets/fonts/Inter-Regular.ttf index cc73944a..b7aaca8d 100644 Binary files a/assets/fonts/Inter-Regular.ttf and b/assets/fonts/Inter-Regular.ttf differ diff --git a/assets/fonts/Inter-SemiBold.ttf b/assets/fonts/Inter-SemiBold.ttf index 278ceaa3..47f8ab1d 100644 Binary files a/assets/fonts/Inter-SemiBold.ttf and b/assets/fonts/Inter-SemiBold.ttf differ diff --git a/assets/fonts/Inter-SemiBoldItalic.ttf b/assets/fonts/Inter-SemiBoldItalic.ttf new file mode 100644 index 00000000..3f704a20 Binary files /dev/null and b/assets/fonts/Inter-SemiBoldItalic.ttf differ diff --git a/assets/fonts/Inter-Thin.ttf b/assets/fonts/Inter-Thin.ttf index e12ed497..4cc53b31 100644 Binary files a/assets/fonts/Inter-Thin.ttf and b/assets/fonts/Inter-Thin.ttf differ diff --git a/assets/fonts/Inter-ThinItalic.ttf b/assets/fonts/Inter-ThinItalic.ttf new file mode 100644 index 00000000..f6527437 Binary files /dev/null and b/assets/fonts/Inter-ThinItalic.ttf differ diff --git a/assets/js/vaft.user.js b/assets/js/vaft.user.js new file mode 100644 index 00000000..47c3e971 --- /dev/null +++ b/assets/js/vaft.user.js @@ -0,0 +1,1119 @@ +// ==UserScript== +// @name TwitchAdSolutions (vaft) +// @namespace https://github.com/pixeltris/TwitchAdSolutions +// @version 37.0.0 +// @description Multiple solutions for blocking Twitch ads (vaft) +// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/vaft/vaft.user.js +// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/vaft/vaft.user.js +// @author https://github.com/cleanlock/VideoAdBlockForTwitch#credits +// @match *://*.twitch.tv/* +// @run-at document-start +// @inject-into page +// @grant none +// ==/UserScript== +(function() { + 'use strict'; + const ourTwitchAdSolutionsVersion = 24;// Used to prevent conflicts with outdated versions of the scripts + if (typeof window.twitchAdSolutionsVersion !== 'undefined' && window.twitchAdSolutionsVersion >= ourTwitchAdSolutionsVersion) { + console.log("skipping vaft as there's another script active. ourVersion:" + ourTwitchAdSolutionsVersion + " activeVersion:" + window.twitchAdSolutionsVersion); + window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; + return; + } + window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; + function declareOptions(scope) { + scope.AdSignifier = 'stitched'; + scope.ClientID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; + scope.BackupPlayerTypes = [ + 'embed',//Source + 'popout',//Source + 'autoplay',//360p + //'picture-by-picture-CACHED'//360p (-CACHED is an internal suffix and is removed) + ]; + scope.FallbackPlayerType = 'embed'; + scope.ForceAccessTokenPlayerType = 'popout'; + scope.SkipPlayerReloadOnHevc = false;// If true this will skip player reload on streams which have 2k/4k quality (if you enable this and you use the 2k/4k quality setting you'll get error #4000 / #3000 / spinning wheel on chrome based browsers) + scope.AlwaysReloadPlayerOnAd = false;// Always pause/play when entering/leaving ads + scope.ReloadPlayerAfterAd = true;// After the ad finishes do a player reload instead of pause/play + scope.PlayerReloadMinimalRequestsTime = 1500; + scope.PlayerReloadMinimalRequestsPlayerIndex = 2;//autoplay + scope.HasTriggeredPlayerReload = false; + scope.StreamInfos = []; + scope.StreamInfosByUrl = []; + scope.GQLDeviceID = null; + scope.ClientVersion = null; + scope.ClientSession = null; + scope.ClientIntegrityHeader = null; + scope.AuthorizationHeader = undefined; + scope.SimulatedAdsDepth = 0; + scope.PlayerBufferingFix = true;// If true this will pause/play the player when it gets stuck buffering + scope.PlayerBufferingDelay = 600;// How often should we check the player state (in milliseconds) + scope.PlayerBufferingSameStateCount = 3;// How many times of seeing the same player state until we trigger pause/play (it will only trigger it one time until the player state changes again) + scope.PlayerBufferingDangerZone = 1;// The buffering time left (in seconds) when we should ignore the players playback position in the player state check + scope.PlayerBufferingDoPlayerReload = false;// If true this will do a player reload instead of pause/play (player reloading is better at fixing the playback issues but it takes slightly longer) + scope.PlayerBufferingMinRepeatDelay = 8000;// Minimum delay (in milliseconds) between each pause/play (this is to avoid over pressing pause/play when there are genuine buffering problems) + scope.PlayerBufferingPrerollCheckEnabled = false;// Enable this if you're getting an immediate pause/play/reload as you open a stream (which is causing the stream to take longer to load). One problem with this being true is that it can cause the player to get stuck in some instances requiring the user to press pause/play + scope.PlayerBufferingPrerollCheckOffset = 5;// How far the stream need to move before doing the buffering mitigation (depends on PlayerBufferingPrerollCheckEnabled being true) + scope.V2API = false; + scope.IsAdStrippingEnabled = true; + scope.AdSegmentCache = new Map(); + scope.AllSegmentsAreAdSegments = false; + } + let isActivelyStrippingAds = false; + let localStorageHookFailed = false; + const twitchWorkers = []; + const workerStringConflicts = [ + 'twitch', + 'isVariantA'// TwitchNoSub + ]; + const workerStringAllow = []; + const workerStringReinsert = [ + 'isVariantA',// TwitchNoSub (prior to (0.9)) + 'besuper/',// TwitchNoSub (0.9) + '${patch_url}'// TwitchNoSub (0.9.1) + ]; + function getCleanWorker(worker) { + let root = null; + let parent = null; + let proto = worker; + while (proto) { + const workerString = proto.toString(); + if (workerStringConflicts.some((x) => workerString.includes(x)) && !workerStringAllow.some((x) => workerString.includes(x))) { + if (parent !== null) { + Object.setPrototypeOf(parent, Object.getPrototypeOf(proto)); + } + } else { + if (root === null) { + root = proto; + } + parent = proto; + } + proto = Object.getPrototypeOf(proto); + } + return root; + } + function getWorkersForReinsert(worker) { + const result = []; + let proto = worker; + while (proto) { + const workerString = proto.toString(); + if (workerStringReinsert.some((x) => workerString.includes(x))) { + result.push(proto); + } else { + } + proto = Object.getPrototypeOf(proto); + } + return result; + } + function reinsertWorkers(worker, reinsert) { + let parent = worker; + for (let i = 0; i < reinsert.length; i++) { + Object.setPrototypeOf(reinsert[i], parent); + parent = reinsert[i]; + } + return parent; + } + function isValidWorker(worker) { + const workerString = worker.toString(); + return !workerStringConflicts.some((x) => workerString.includes(x)) + || workerStringAllow.some((x) => workerString.includes(x)) + || workerStringReinsert.some((x) => workerString.includes(x)); + } + function hookWindowWorker() { + const reinsert = getWorkersForReinsert(window.Worker); + const newWorker = class Worker extends getCleanWorker(window.Worker) { + constructor(twitchBlobUrl, options) { + let isTwitchWorker = false; + try { + isTwitchWorker = new URL(twitchBlobUrl).origin.endsWith('.twitch.tv'); + } catch {} + if (!isTwitchWorker) { + super(twitchBlobUrl, options); + return; + } + const newBlobStr = ` + const pendingFetchRequests = new Map(); + ${stripAdSegments.toString()} + ${getStreamUrlForResolution.toString()} + ${processM3U8.toString()} + ${hookWorkerFetch.toString()} + ${declareOptions.toString()} + ${getAccessToken.toString()} + ${gqlRequest.toString()} + ${parseAttributes.toString()} + ${getWasmWorkerJs.toString()} + ${getServerTimeFromM3u8.toString()} + ${replaceServerTimeInM3u8.toString()} + const workerString = getWasmWorkerJs('${twitchBlobUrl.replaceAll("'", "%27")}'); + declareOptions(self); + GQLDeviceID = ${GQLDeviceID ? "'" + GQLDeviceID + "'" : null}; + AuthorizationHeader = ${AuthorizationHeader ? "'" + AuthorizationHeader + "'" : undefined}; + ClientIntegrityHeader = ${ClientIntegrityHeader ? "'" + ClientIntegrityHeader + "'" : null}; + ClientVersion = ${ClientVersion ? "'" + ClientVersion + "'" : null}; + ClientSession = ${ClientSession ? "'" + ClientSession + "'" : null}; + self.addEventListener('message', function(e) { + if (e.data.key == 'UpdateClientVersion') { + ClientVersion = e.data.value; + } else if (e.data.key == 'UpdateClientSession') { + ClientSession = e.data.value; + } else if (e.data.key == 'UpdateClientId') { + ClientID = e.data.value; + } else if (e.data.key == 'UpdateDeviceId') { + GQLDeviceID = e.data.value; + } else if (e.data.key == 'UpdateClientIntegrityHeader') { + ClientIntegrityHeader = e.data.value; + } else if (e.data.key == 'UpdateAuthorizationHeader') { + AuthorizationHeader = e.data.value; + } else if (e.data.key == 'FetchResponse') { + const responseData = e.data.value; + if (pendingFetchRequests.has(responseData.id)) { + const { resolve, reject } = pendingFetchRequests.get(responseData.id); + pendingFetchRequests.delete(responseData.id); + if (responseData.error) { + reject(new Error(responseData.error)); + } else { + // Create a Response object from the response data + const response = new Response(responseData.body, { + status: responseData.status, + statusText: responseData.statusText, + headers: responseData.headers + }); + resolve(response); + } + } + } else if (e.data.key == 'TriggeredPlayerReload') { + HasTriggeredPlayerReload = true; + } else if (e.data.key == 'SimulateAds') { + SimulatedAdsDepth = e.data.value; + console.log('SimulatedAdsDepth: ' + SimulatedAdsDepth); + } else if (e.data.key == 'AllSegmentsAreAdSegments') { + AllSegmentsAreAdSegments = !AllSegmentsAreAdSegments; + console.log('AllSegmentsAreAdSegments: ' + AllSegmentsAreAdSegments); + } + }); + hookWorkerFetch(); + eval(workerString); + `; + super(URL.createObjectURL(new Blob([newBlobStr])), options); + twitchWorkers.push(this); + this.addEventListener('message', (e) => { + if (e.data.key == 'UpdateAdBlockBanner') { + updateAdblockBanner(e.data); + } else if (e.data.key == 'PauseResumePlayer') { + doTwitchPlayerTask(true, false); + } else if (e.data.key == 'ReloadPlayer') { + doTwitchPlayerTask(false, true); + } + }); + this.addEventListener('message', async event => { + if (event.data.key == 'FetchRequest') { + const fetchRequest = event.data.value; + const responseData = await handleWorkerFetchRequest(fetchRequest); + this.postMessage({ + key: 'FetchResponse', + value: responseData + }); + } + }); + } + }; + let workerInstance = reinsertWorkers(newWorker, reinsert); + Object.defineProperty(window, 'Worker', { + get: function() { + return workerInstance; + }, + set: function(value) { + if (isValidWorker(value)) { + workerInstance = value; + } else { + console.log('Attempt to set twitch worker denied'); + } + } + }); + } + function getWasmWorkerJs(twitchBlobUrl) { + const req = new XMLHttpRequest(); + req.open('GET', twitchBlobUrl, false); + req.overrideMimeType("text/javascript"); + req.send(); + return req.responseText; + } + function hookWorkerFetch() { + console.log('hookWorkerFetch (vaft)'); + const realFetch = fetch; + fetch = async function(url, options) { + if (typeof url === 'string') { + if (AdSegmentCache.has(url)) { + return new Promise(function(resolve, reject) { + const send = function() { + return realFetch('data:video/mp4;base64,AAAAKGZ0eXBtcDQyAAAAAWlzb21tcDQyZGFzaGF2YzFpc282aGxzZgAABEltb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAYagAAAAAAABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAABqHRyYWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAURtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAALuAAAAAAFXEAAAAAAAtaGRscgAAAAAAAAAAc291bgAAAAAAAAAAAAAAAFNvdW5kSGFuZGxlcgAAAADvbWluZgAAABBzbWhkAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAACzc3RibAAAAGdzdHNkAAAAAAAAAAEAAABXbXA0YQAAAAAAAAABAAAAAAAAAAAAAgAQAAAAALuAAAAAAAAzZXNkcwAAAAADgICAIgABAASAgIAUQBUAAAAAAAAAAAAAAAWAgIACEZAGgICAAQIAAAAQc3R0cwAAAAAAAAAAAAAAEHN0c2MAAAAAAAAAAAAAABRzdHN6AAAAAAAAAAAAAAAAAAAAEHN0Y28AAAAAAAAAAAAAAeV0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAoAAAAFoAAAAAAGBbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAA9CQAAAAABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABLG1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAOxzdGJsAAAAoHN0c2QAAAAAAAAAAQAAAJBhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAoABaABIAAAASAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAOmF2Y0MBTUAe/+EAI2dNQB6WUoFAX/LgLUBAQFAAAD6AAA6mDgAAHoQAA9CW7y4KAQAEaOuPIAAAABBzdHRzAAAAAAAAAAAAAAAQc3RzYwAAAAAAAAAAAAAAFHN0c3oAAAAAAAAAAAAAAAAAAAAQc3RjbwAAAAAAAAAAAAAASG12ZXgAAAAgdHJleAAAAAAAAAABAAAAAQAAAC4AAAAAAoAAAAAAACB0cmV4AAAAAAAAAAIAAAABAACCNQAAAAACQAAA', options).then(function(response) { + resolve(response); + })['catch'](function(err) { + reject(err); + }); + }; + send(); + }); + } + url = url.trimEnd(); + if (url.endsWith('m3u8')) { + return new Promise(function(resolve, reject) { + const processAfter = async function(response) { + if (response.status === 200) { + resolve(new Response(await processM3U8(url, await response.text(), realFetch))); + } else { + resolve(response); + } + }; + const send = function() { + return realFetch(url, options).then(function(response) { + processAfter(response); + })['catch'](function(err) { + reject(err); + }); + }; + send(); + }); + } else if (url.includes('/channel/hls/') && !url.includes('picture-by-picture')) { + V2API = url.includes('/api/v2/'); + const channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; + if (ForceAccessTokenPlayerType) { + // parent_domains is used to determine if the player is embeded and stripping it gets rid of fake ads + const tempUrl = new URL(url); + tempUrl.searchParams.delete('parent_domains'); + url = tempUrl.toString(); + } + return new Promise(function(resolve, reject) { + const processAfter = async function(response) { + if (response.status == 200) { + const encodingsM3u8 = await response.text(); + const serverTime = getServerTimeFromM3u8(encodingsM3u8); + let streamInfo = StreamInfos[channelName]; + if (streamInfo != null && streamInfo.EncodingsM3U8 != null && (await realFetch(streamInfo.EncodingsM3U8.match(/^https:.*\.m3u8$/m)[0])).status !== 200) { + // The cached encodings are dead (the stream probably restarted) + streamInfo = null; + } + if (streamInfo == null || streamInfo.EncodingsM3U8 == null) { + StreamInfos[channelName] = streamInfo = { + ChannelName: channelName, + IsShowingAd: false, + LastPlayerReload: 0, + EncodingsM3U8: encodingsM3u8, + ModifiedM3U8: null, + IsUsingModifiedM3U8: false, + UsherParams: (new URL(url)).search, + RequestedAds: new Set(), + Urls: [],// xxx.m3u8 -> { Resolution: "284x160", FrameRate: 30.0 } + ResolutionList: [], + BackupEncodingsM3U8Cache: [], + ActiveBackupPlayerType: null, + IsMidroll: false, + IsStrippingAdSegments: false, + NumStrippedAdSegments: 0 + }; + const lines = encodingsM3u8.replaceAll('\r', '').split('\n'); + for (let i = 0; i < lines.length - 1; i++) { + if (lines[i].startsWith('#EXT-X-STREAM-INF') && lines[i + 1].includes('.m3u8')) { + const attributes = parseAttributes(lines[i]); + const resolution = attributes['RESOLUTION']; + if (resolution) { + const resolutionInfo = { + Resolution: resolution, + FrameRate: attributes['FRAME-RATE'], + Codecs: attributes['CODECS'], + Url: lines[i + 1] + }; + streamInfo.Urls[lines[i + 1]] = resolutionInfo; + streamInfo.ResolutionList.push(resolutionInfo); + } + StreamInfosByUrl[lines[i + 1]] = streamInfo; + } + } + const nonHevcResolutionList = streamInfo.ResolutionList.filter((element) => element.Codecs.startsWith('avc') || element.Codecs.startsWith('av0')); + if (AlwaysReloadPlayerOnAd || (nonHevcResolutionList.length > 0 && streamInfo.ResolutionList.some((element) => element.Codecs.startsWith('hev') || element.Codecs.startsWith('hvc')) && !SkipPlayerReloadOnHevc)) { + if (nonHevcResolutionList.length > 0) { + for (let i = 0; i < lines.length - 1; i++) { + if (lines[i].startsWith('#EXT-X-STREAM-INF')) { + const resSettings = parseAttributes(lines[i].substring(lines[i].indexOf(':') + 1)); + const codecsKey = 'CODECS'; + if (resSettings[codecsKey].startsWith('hev') || resSettings[codecsKey].startsWith('hvc')) { + const oldResolution = resSettings['RESOLUTION']; + const [targetWidth, targetHeight] = oldResolution.split('x').map(Number); + const newResolutionInfo = nonHevcResolutionList.sort((a, b) => { + // TODO: Take into account 'Frame-Rate' when sorting (i.e. 1080p60 vs 1080p30) + const [streamWidthA, streamHeightA] = a.Resolution.split('x').map(Number); + const [streamWidthB, streamHeightB] = b.Resolution.split('x').map(Number); + return Math.abs((streamWidthA * streamHeightA) - (targetWidth * targetHeight)) - Math.abs((streamWidthB * streamHeightB) - (targetWidth * targetHeight)); + })[0]; + console.log('ModifiedM3U8 swap ' + resSettings[codecsKey] + ' to ' + newResolutionInfo.Codecs + ' oldRes:' + oldResolution + ' newRes:' + newResolutionInfo.Resolution); + lines[i] = lines[i].replace(/CODECS="[^"]+"/, `CODECS="${newResolutionInfo.Codecs}"`); + lines[i + 1] = newResolutionInfo.Url + ' '.repeat(i + 1);// The stream doesn't load unless each url line is unique + } + } + } + } + if (nonHevcResolutionList.length > 0 || AlwaysReloadPlayerOnAd) { + streamInfo.ModifiedM3U8 = lines.join('\n'); + } + } + } + streamInfo.LastPlayerReload = Date.now(); + resolve(new Response(replaceServerTimeInM3u8(streamInfo.IsUsingModifiedM3U8 ? streamInfo.ModifiedM3U8 : streamInfo.EncodingsM3U8, serverTime))); + } else { + resolve(response); + } + }; + const send = function() { + return realFetch(url, options).then(function(response) { + processAfter(response); + })['catch'](function(err) { + reject(err); + }); + }; + send(); + }); + } + } + return realFetch.apply(this, arguments); + }; + } + function getServerTimeFromM3u8(encodingsM3u8) { + if (V2API) { + const matches = encodingsM3u8.match(/#EXT-X-SESSION-DATA:DATA-ID="SERVER-TIME",VALUE="([^"]+)"/); + return matches.length > 1 ? matches[1] : null; + } + const matches = encodingsM3u8.match('SERVER-TIME="([0-9.]+)"'); + return matches.length > 1 ? matches[1] : null; + } + function replaceServerTimeInM3u8(encodingsM3u8, newServerTime) { + if (V2API) { + return newServerTime ? encodingsM3u8.replace(/(#EXT-X-SESSION-DATA:DATA-ID="SERVER-TIME",VALUE=")[^"]+(")/, `$1${newServerTime}$2`) : encodingsM3u8; + } + return newServerTime ? encodingsM3u8.replace(new RegExp('(SERVER-TIME=")[0-9.]+"'), `SERVER-TIME="${newServerTime}"`) : encodingsM3u8; + } + function stripAdSegments(textStr, stripAllSegments, streamInfo) { + let hasStrippedAdSegments = false; + const lines = textStr.replaceAll('\r', '').split('\n'); + const newAdUrl = 'https://twitch.tv'; + for (let i = 0; i < lines.length; i++) { + let line = lines[i]; + // Remove tracking urls which appear in the overlay UI + line = line + .replaceAll(/(X-TV-TWITCH-AD-URL=")(?:[^"]*)(")/g, `$1${newAdUrl}$2`) + .replaceAll(/(X-TV-TWITCH-AD-CLICK-TRACKING-URL=")(?:[^"]*)(")/g, `$1${newAdUrl}$2`); + if (i < lines.length - 1 && line.startsWith('#EXTINF') && (!line.includes(',live') || stripAllSegments || AllSegmentsAreAdSegments)) { + const segmentUrl = lines[i + 1]; + if (!AdSegmentCache.has(segmentUrl)) { + streamInfo.NumStrippedAdSegments++; + } + AdSegmentCache.set(segmentUrl, Date.now()); + hasStrippedAdSegments = true; + } + if (line.includes(AdSignifier)) { + hasStrippedAdSegments = true; + } + } + if (hasStrippedAdSegments) { + for (let i = 0; i < lines.length; i++) { + // No low latency during ads (otherwise it's possible for the player to prefetch and display ad segments) + if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) { + lines[i] = ''; + } + } + } else { + streamInfo.NumStrippedAdSegments = 0; + } + streamInfo.IsStrippingAdSegments = hasStrippedAdSegments; + AdSegmentCache.forEach((value, key, map) => { + if (value < Date.now() - 120000) { + map.delete(key); + } + }); + return lines.join('\n'); + } + function getStreamUrlForResolution(encodingsM3u8, resolutionInfo) { + const encodingsLines = encodingsM3u8.replaceAll('\r', '').split('\n'); + const [targetWidth, targetHeight] = resolutionInfo.Resolution.split('x').map(Number); + let matchedResolutionUrl = null; + let matchedFrameRate = false; + let closestResolutionUrl = null; + let closestResolutionDifference = Infinity; + for (let i = 0; i < encodingsLines.length - 1; i++) { + if (encodingsLines[i].startsWith('#EXT-X-STREAM-INF') && encodingsLines[i + 1].includes('.m3u8')) { + const attributes = parseAttributes(encodingsLines[i]); + const resolution = attributes['RESOLUTION']; + const frameRate = attributes['FRAME-RATE']; + if (resolution) { + if (resolution == resolutionInfo.Resolution && (!matchedResolutionUrl || (!matchedFrameRate && frameRate == resolutionInfo.FrameRate))) { + matchedResolutionUrl = encodingsLines[i + 1]; + matchedFrameRate = frameRate == resolutionInfo.FrameRate; + if (matchedFrameRate) { + return matchedResolutionUrl; + } + } + const [width, height] = resolution.split('x').map(Number); + const difference = Math.abs((width * height) - (targetWidth * targetHeight)); + if (difference < closestResolutionDifference) { + closestResolutionUrl = encodingsLines[i + 1]; + closestResolutionDifference = difference; + } + } + } + } + return closestResolutionUrl; + } + async function processM3U8(url, textStr, realFetch) { + const streamInfo = StreamInfosByUrl[url]; + if (!streamInfo) { + return textStr; + } + if (HasTriggeredPlayerReload) { + HasTriggeredPlayerReload = false; + streamInfo.LastPlayerReload = Date.now(); + } + const haveAdTags = textStr.includes(AdSignifier) || SimulatedAdsDepth > 0; + if (haveAdTags) { + streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); + if (!streamInfo.IsShowingAd) { + streamInfo.IsShowingAd = true; + postMessage({ + key: 'UpdateAdBlockBanner', + isMidroll: streamInfo.IsMidroll, + hasAds: streamInfo.IsShowingAd, + isStrippingAdSegments: false + }); + } + if (!streamInfo.IsMidroll) { + const lines = textStr.replaceAll('\r', '').split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line.startsWith('#EXTINF') && lines.length > i + 1) { + if (!line.includes(',live') && !streamInfo.RequestedAds.has(lines[i + 1])) { + // Only request one .ts file per .m3u8 request to avoid making too many requests + //console.log('Fetch ad .ts file'); + streamInfo.RequestedAds.add(lines[i + 1]); + fetch(lines[i + 1]).then((response)=>{response.blob()}); + break; + } + } + } + } + const currentResolution = streamInfo.Urls[url]; + if (!currentResolution) { + console.log('Ads will leak due to missing resolution info for ' + url); + return textStr; + } + const isHevc = currentResolution.Codecs.startsWith('hev') || currentResolution.Codecs.startsWith('hvc'); + if (((isHevc && !SkipPlayerReloadOnHevc) || AlwaysReloadPlayerOnAd) && streamInfo.ModifiedM3U8 && !streamInfo.IsUsingModifiedM3U8) { + streamInfo.IsUsingModifiedM3U8 = true; + streamInfo.LastPlayerReload = Date.now(); + postMessage({ + key: 'ReloadPlayer' + }); + } + let backupPlayerType = null; + let backupM3u8 = null; + let fallbackM3u8 = null; + let startIndex = 0; + let isDoingMinimalRequests = false; + if (streamInfo.LastPlayerReload > Date.now() - PlayerReloadMinimalRequestsTime) { + // When doing player reload there are a lot of requests which causes the backup stream to load in slow. Briefly prefer using a single version to prevent long delays + startIndex = PlayerReloadMinimalRequestsPlayerIndex; + isDoingMinimalRequests = true; + } + for (let playerTypeIndex = startIndex; !backupM3u8 && playerTypeIndex < BackupPlayerTypes.length; playerTypeIndex++) { + const playerType = BackupPlayerTypes[playerTypeIndex]; + const realPlayerType = playerType.replace('-CACHED', ''); + const isFullyCachedPlayerType = playerType != realPlayerType; + for (let i = 0; i < 2; i++) { + // This caches the m3u8 if it doesn't have ads. If the already existing cache has ads it fetches a new version (second loop) + let isFreshM3u8 = false; + let encodingsM3u8 = streamInfo.BackupEncodingsM3U8Cache[playerType]; + if (!encodingsM3u8) { + isFreshM3u8 = true; + try { + const accessTokenResponse = await getAccessToken(streamInfo.ChannelName, realPlayerType); + if (accessTokenResponse.status === 200) { + const accessToken = await accessTokenResponse.json(); + const urlInfo = new URL('https://usher.ttvnw.net/api/' + (V2API ? 'v2/' : '') + 'channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.UsherParams); + urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); + urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); + const encodingsM3u8Response = await realFetch(urlInfo.href); + if (encodingsM3u8Response.status === 200) { + encodingsM3u8 = streamInfo.BackupEncodingsM3U8Cache[playerType] = await encodingsM3u8Response.text(); + } + } + } catch (err) {} + } + if (encodingsM3u8) { + try { + const streamM3u8Url = getStreamUrlForResolution(encodingsM3u8, currentResolution); + const streamM3u8Response = await realFetch(streamM3u8Url); + if (streamM3u8Response.status == 200) { + const m3u8Text = await streamM3u8Response.text(); + if (m3u8Text) { + if (playerType == FallbackPlayerType) { + fallbackM3u8 = m3u8Text; + } + if ((!m3u8Text.includes(AdSignifier) && (SimulatedAdsDepth == 0 || playerTypeIndex >= SimulatedAdsDepth - 1)) || (!fallbackM3u8 && playerTypeIndex >= BackupPlayerTypes.length - 1)) { + backupPlayerType = playerType; + backupM3u8 = m3u8Text; + break; + } + if (isFullyCachedPlayerType) { + break; + } + if (isDoingMinimalRequests) { + backupPlayerType = playerType; + backupM3u8 = m3u8Text; + break; + } + } + } + } catch (err) {} + } + streamInfo.BackupEncodingsM3U8Cache[playerType] = null; + if (isFreshM3u8) { + break; + } + } + } + if (!backupM3u8 && fallbackM3u8) { + backupPlayerType = FallbackPlayerType; + backupM3u8 = fallbackM3u8; + } + if (backupM3u8) { + textStr = backupM3u8; + if (streamInfo.ActiveBackupPlayerType != backupPlayerType) { + streamInfo.ActiveBackupPlayerType = backupPlayerType; + console.log(`Blocking${(streamInfo.IsMidroll ? ' midroll ' : ' ')}ads (${backupPlayerType})`); + } + } + // TODO: Improve hevc stripping. It should always strip when there is a codec mismatch (both ways) + const stripHevc = isHevc && streamInfo.ModifiedM3U8; + if (IsAdStrippingEnabled || stripHevc) { + textStr = stripAdSegments(textStr, stripHevc, streamInfo); + } + } else if (streamInfo.IsShowingAd) { + console.log('Finished blocking ads'); + streamInfo.IsShowingAd = false; + streamInfo.IsStrippingAdSegments = false; + streamInfo.NumStrippedAdSegments = 0; + streamInfo.ActiveBackupPlayerType = null; + if (streamInfo.IsUsingModifiedM3U8 || ReloadPlayerAfterAd) { + streamInfo.IsUsingModifiedM3U8 = false; + streamInfo.LastPlayerReload = Date.now(); + postMessage({ + key: 'ReloadPlayer' + }); + } else { + postMessage({ + key: 'PauseResumePlayer' + }); + } + } + postMessage({ + key: 'UpdateAdBlockBanner', + isMidroll: streamInfo.IsMidroll, + hasAds: streamInfo.IsShowingAd, + isStrippingAdSegments: streamInfo.IsStrippingAdSegments, + numStrippedAdSegments: streamInfo.NumStrippedAdSegments + }); + return textStr; + } + function parseAttributes(str) { + return Object.fromEntries( + str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/) + .filter(Boolean) + .map(x => { + const idx = x.indexOf('='); + const key = x.substring(0, idx); + const value = x.substring(idx + 1); + const num = Number(value); + return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num]; + })); + } + function getAccessToken(channelName, playerType) { + const body = { + operationName: 'PlaybackAccessToken', + variables: { + isLive: true, + login: channelName, + isVod: false, + vodID: "", + playerType: playerType, + platform: playerType == 'autoplay' ? 'android' : 'web' + }, + extensions: { + persistedQuery: { + version:1, + sha256Hash:"ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9" + } + } + }; + return gqlRequest(body, playerType); + } + function gqlRequest(body, playerType) { + if (!GQLDeviceID) { + GQLDeviceID = ''; + const dcharacters = 'abcdefghijklmnopqrstuvwxyz0123456789'; + const dcharactersLength = dcharacters.length; + for (let i = 0; i < 32; i++) { + GQLDeviceID += dcharacters.charAt(Math.floor(Math.random() * dcharactersLength)); + } + } + let headers = { + 'Client-ID': ClientID, + 'X-Device-Id': GQLDeviceID, + 'Authorization': AuthorizationHeader, + ...(ClientIntegrityHeader && {'Client-Integrity': ClientIntegrityHeader}), + ...(ClientVersion && {'Client-Version': ClientVersion}), + ...(ClientSession && {'Client-Session-Id': ClientSession}) + }; + return new Promise((resolve, reject) => { + const requestId = Math.random().toString(36).substring(2, 15); + const fetchRequest = { + id: requestId, + url: 'https://gql.twitch.tv/gql', + options: { + method: 'POST', + body: JSON.stringify(body), + headers + } + }; + pendingFetchRequests.set(requestId, { + resolve, + reject + }); + postMessage({ + key: 'FetchRequest', + value: fetchRequest + }); + }); + } + let playerForMonitoringBuffering = null; + const playerBufferState = { + channelName: null, + hasStreamStarted: false, + position: 0, + bufferedPosition: 0, + bufferDuration: 0, + numSame: 0, + lastFixTime: 0, + isLive: true + }; + function monitorPlayerBuffering() { + if (playerForMonitoringBuffering) { + try { + const player = playerForMonitoringBuffering.player; + const state = playerForMonitoringBuffering.state; + if (!player.core) { + playerForMonitoringBuffering = null; + } else if (state.props?.content?.type === 'live' && !player.isPaused() && !player.getHTMLVideoElement()?.ended && playerBufferState.lastFixTime <= Date.now() - PlayerBufferingMinRepeatDelay && !isActivelyStrippingAds) { + const m3u8Url = player.core?.state?.path; + if (m3u8Url) { + const fileName = new URL(m3u8Url).pathname.split('/').pop(); + if (fileName?.endsWith('.m3u8')) { + const channelName = fileName.slice(0, -5); + if (playerBufferState.channelName != channelName) { + playerBufferState.channelName = channelName; + playerBufferState.hasStreamStarted = false; + playerBufferState.numSame = 0; + //console.log('Channel changed to ' + channelName); + } + } + } + if (player.getState() === 'Playing') { + playerBufferState.hasStreamStarted = true; + } + const position = player.core?.state?.position; + const bufferedPosition = player.core?.state?.bufferedPosition; + const bufferDuration = player.getBufferDuration(); + if (position !== undefined && bufferedPosition !== undefined) { + //console.log('position:' + position + ' bufferDuration:' + bufferDuration + ' bufferPosition:' + bufferedPosition + ' state: ' + player.core?.state?.state + ' started: ' + playerBufferState.hasStreamStarted); + // NOTE: This could be improved. It currently lets the player fully eat the full buffer before it triggers pause/play + if (playerBufferState.hasStreamStarted && + (!PlayerBufferingPrerollCheckEnabled || position > PlayerBufferingPrerollCheckOffset) && + (playerBufferState.position == position || bufferDuration < PlayerBufferingDangerZone) && + playerBufferState.bufferedPosition == bufferedPosition && + playerBufferState.bufferDuration >= bufferDuration && + (position != 0 || bufferedPosition != 0 || bufferDuration != 0) + ) { + playerBufferState.numSame++; + if (playerBufferState.numSame == PlayerBufferingSameStateCount) { + console.log('Attempt to fix buffering position:' + playerBufferState.position + ' bufferedPosition:' + playerBufferState.bufferedPosition + ' bufferDuration:' + playerBufferState.bufferDuration); + const isPausePlay = !PlayerBufferingDoPlayerReload; + const isReload = PlayerBufferingDoPlayerReload; + doTwitchPlayerTask(isPausePlay, isReload); + playerBufferState.lastFixTime = Date.now(); + playerBufferState.numSame = 0; + } + } else { + playerBufferState.numSame = 0; + } + playerBufferState.position = position; + playerBufferState.bufferedPosition = bufferedPosition; + playerBufferState.bufferDuration = bufferDuration; + } else { + playerBufferState.numSame = 0; + } + } + } catch (err) { + console.error('error when monitoring player for buffering: ' + err); + playerForMonitoringBuffering = null; + } + } + if (!playerForMonitoringBuffering) { + const playerAndState = getPlayerAndState(); + if (playerAndState && playerAndState.player && playerAndState.state) { + playerForMonitoringBuffering = { + player: playerAndState.player, + state: playerAndState.state + }; + } + } + const isLive = playerForMonitoringBuffering?.state?.props?.content?.type === 'live'; + if (playerBufferState.isLive && !isLive) { + updateAdblockBanner({ + hasAds: false + }); + } + playerBufferState.isLive = isLive; + setTimeout(monitorPlayerBuffering, PlayerBufferingDelay); + } + function updateAdblockBanner(data) { + const playerRootDiv = document.querySelector('.video-player'); + if (playerRootDiv != null) { + let adBlockDiv = null; + adBlockDiv = playerRootDiv.querySelector('.adblock-overlay'); + if (adBlockDiv == null) { + adBlockDiv = document.createElement('div'); + adBlockDiv.className = 'adblock-overlay'; + adBlockDiv.innerHTML = '

'; + adBlockDiv.style.display = 'none'; + adBlockDiv.P = adBlockDiv.querySelector('p'); + playerRootDiv.appendChild(adBlockDiv); + } + if (adBlockDiv != null) { + isActivelyStrippingAds = data.isStrippingAdSegments; + adBlockDiv.P.textContent = 'Blocking' + (data.isMidroll ? ' midroll' : '') + ' ads' + (data.isStrippingAdSegments ? ' (stripping)' : '');// + (data.numStrippedAdSegments > 0 ? ` (${data.numStrippedAdSegments})` : ''); + adBlockDiv.style.display = data.hasAds && playerBufferState.isLive ? 'block' : 'none'; + } + } + } + function getPlayerAndState() { + function findReactNode(root, constraint) { + if (root.stateNode && constraint(root.stateNode)) { + return root.stateNode; + } + let node = root.child; + while (node) { + const result = findReactNode(node, constraint); + if (result) { + return result; + } + node = node.sibling; + } + return null; + } + function findReactRootNode() { + let reactRootNode = null; + const rootNode = document.querySelector('#root'); + if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { + reactRootNode = rootNode._reactRootContainer._internalRoot.current; + } + if (reactRootNode == null && rootNode != null) { + const containerName = Object.keys(rootNode).find(x => x.startsWith('__reactContainer')); + if (containerName != null) { + reactRootNode = rootNode[containerName]; + } + } + return reactRootNode; + } + const reactRootNode = findReactRootNode(); + if (!reactRootNode) { + return null; + } + let player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); + player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; + if (player?.playerInstance) { + player = player.playerInstance; + } + const playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings); + return { + player: player, + state: playerState + }; + } + function doTwitchPlayerTask(isPausePlay, isReload) { + const playerAndState = getPlayerAndState(); + if (!playerAndState) { + console.log('Could not find react root'); + return; + } + const player = playerAndState.player; + const playerState = playerAndState.state; + if (!player) { + console.log('Could not find player'); + return; + } + if (!playerState) { + console.log('Could not find player state'); + return; + } + if (player.isPaused() || player.core?.paused) { + return; + } + playerBufferState.lastFixTime = Date.now(); + playerBufferState.numSame = 0; + if (isPausePlay) { + player.pause(); + player.play(); + return; + } + if (isReload) { + const lsKeyQuality = 'video-quality'; + const lsKeyMuted = 'video-muted'; + const lsKeyVolume = 'volume'; + let currentQualityLS = null; + let currentMutedLS = null; + let currentVolumeLS = null; + try { + currentQualityLS = localStorage.getItem(lsKeyQuality); + currentMutedLS = localStorage.getItem(lsKeyMuted); + currentVolumeLS = localStorage.getItem(lsKeyVolume); + if (localStorageHookFailed && player?.core?.state) { + localStorage.setItem(lsKeyMuted, JSON.stringify({default:player.core.state.muted})); + localStorage.setItem(lsKeyVolume, player.core.state.volume); + } + if (localStorageHookFailed && player?.core?.state?.quality?.group) { + localStorage.setItem(lsKeyQuality, JSON.stringify({default:player.core.state.quality.group})); + } + } catch {} + console.log('Reloading Twitch player'); + playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true }); + postTwitchWorkerMessage('TriggeredPlayerReload'); + player.play(); + if (localStorageHookFailed && (currentQualityLS || currentMutedLS || currentVolumeLS)) { + setTimeout(() => { + try { + if (currentQualityLS) { + localStorage.setItem(lsKeyQuality, currentQualityLS); + } + if (currentMutedLS) { + localStorage.setItem(lsKeyMuted, currentMutedLS); + } + if (currentVolumeLS) { + localStorage.setItem(lsKeyVolume, currentVolumeLS); + } + } catch {} + }, 3000); + } + return; + } + } + window.reloadTwitchPlayer = () => { + doTwitchPlayerTask(false, true); + }; + function postTwitchWorkerMessage(key, value) { + twitchWorkers.forEach((worker) => { + worker.postMessage({key: key, value: value}); + }); + } + async function handleWorkerFetchRequest(fetchRequest) { + try { + const response = await window.realFetch(fetchRequest.url, fetchRequest.options); + const responseBody = await response.text(); + const responseObject = { + id: fetchRequest.id, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: responseBody + }; + return responseObject; + } catch (error) { + return { + id: fetchRequest.id, + error: error.message + }; + } + } + function hookFetch() { + const realFetch = window.fetch; + window.realFetch = realFetch; + window.fetch = function(url, init, ...args) { + if (typeof url === 'string') { + if (url.includes('gql')) { + let deviceId = init.headers['X-Device-Id']; + if (typeof deviceId !== 'string') { + deviceId = init.headers['Device-ID']; + } + if (typeof deviceId === 'string' && GQLDeviceID != deviceId) { + GQLDeviceID = deviceId; + postTwitchWorkerMessage('UpdateDeviceId', GQLDeviceID); + } + if (typeof init.headers['Client-Version'] === 'string' && init.headers['Client-Version'] !== ClientVersion) { + postTwitchWorkerMessage('UpdateClientVersion', ClientVersion = init.headers['Client-Version']); + } + if (typeof init.headers['Client-Session-Id'] === 'string' && init.headers['Client-Session-Id'] !== ClientSession) { + postTwitchWorkerMessage('UpdateClientSession', ClientSession = init.headers['Client-Session-Id']); + } + if (typeof init.headers['Client-Integrity'] === 'string' && init.headers['Client-Integrity'] !== ClientIntegrityHeader) { + postTwitchWorkerMessage('UpdateClientIntegrityHeader', ClientIntegrityHeader = init.headers['Client-Integrity']); + } + if (typeof init.headers['Authorization'] === 'string' && init.headers['Authorization'] !== AuthorizationHeader) { + postTwitchWorkerMessage('UpdateAuthorizationHeader', AuthorizationHeader = init.headers['Authorization']); + } + // Get rid of mini player above chat - TODO: Reject this locally instead of having server reject it + if (init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken') && init.body.includes('picture-by-picture')) { + init.body = ''; + } + if (ForceAccessTokenPlayerType && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { + let replacedPlayerType = ''; + const newBody = JSON.parse(init.body); + if (Array.isArray(newBody)) { + for (let i = 0; i < newBody.length; i++) { + if (newBody[i]?.variables?.playerType && newBody[i]?.variables?.playerType !== ForceAccessTokenPlayerType) { + replacedPlayerType = newBody[i].variables.playerType; + newBody[i].variables.playerType = ForceAccessTokenPlayerType; + } + } + } else { + if (newBody?.variables?.playerType && newBody?.variables?.playerType !== ForceAccessTokenPlayerType) { + replacedPlayerType = newBody.variables.playerType; + newBody.variables.playerType = ForceAccessTokenPlayerType; + } + } + if (replacedPlayerType) { + console.log(`Replaced '${replacedPlayerType}' player type with '${ForceAccessTokenPlayerType}' player type`); + init.body = JSON.stringify(newBody); + } + } + } + } + return realFetch.apply(this, arguments); + }; + } + function onContentLoaded() { + // This stops Twitch from pausing the player when in another tab and an ad shows. + // Taken from https://github.com/saucettv/VideoAdBlockForTwitch/blob/cefce9d2b565769c77e3666ac8234c3acfe20d83/chrome/content.js#L30 + try { + Object.defineProperty(document, 'visibilityState', { + get() { + return 'visible'; + } + }); + }catch{} + let hidden = document.__lookupGetter__('hidden'); + let webkitHidden = document.__lookupGetter__('webkitHidden'); + try { + Object.defineProperty(document, 'hidden', { + get() { + return false; + } + }); + }catch{} + const block = e => { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + }; + let wasVideoPlaying = true; + const visibilityChange = e => { + const isChrome = typeof chrome !== 'undefined'; + const videos = document.getElementsByTagName('video'); + if (videos.length > 0) { + if (hidden.apply(document) === true || (webkitHidden && webkitHidden.apply(document) === true)) { + wasVideoPlaying = !videos[0].paused && !videos[0].ended; + } else { + if (!playerBufferState.hasStreamStarted) { + //console.log('Tab focused. Stream should be active'); + playerBufferState.hasStreamStarted = true; + } + if (isChrome && wasVideoPlaying && !videos[0].ended && videos[0].paused && videos[0].muted) { + videos[0].play(); + } + } + } + block(e); + }; + document.addEventListener('visibilitychange', visibilityChange, true); + document.addEventListener('webkitvisibilitychange', visibilityChange, true); + document.addEventListener('mozvisibilitychange', visibilityChange, true); + document.addEventListener('hasFocus', block, true); + try { + if (/Firefox/.test(navigator.userAgent)) { + Object.defineProperty(document, 'mozHidden', { + get() { + return false; + } + }); + } else { + Object.defineProperty(document, 'webkitHidden', { + get() { + return false; + } + }); + } + }catch{} + // Hooks for preserving volume / resolution + try { + const keysToCache = [ + 'video-quality', + 'video-muted', + 'volume', + 'lowLatencyModeEnabled',// Low Latency + 'persistenceEnabled',// Mini Player + ]; + const cachedValues = new Map(); + for (let i = 0; i < keysToCache.length; i++) { + cachedValues.set(keysToCache[i], localStorage.getItem(keysToCache[i])); + } + const realSetItem = localStorage.setItem; + localStorage.setItem = function(key, value) { + if (cachedValues.has(key)) { + cachedValues.set(key, value); + } + realSetItem.apply(this, arguments); + }; + const realGetItem = localStorage.getItem; + localStorage.getItem = function(key) { + if (cachedValues.has(key)) { + return cachedValues.get(key); + } + return realGetItem.apply(this, arguments); + }; + if (!localStorage.getItem.toString().includes(Object.keys({cachedValues})[0])) { + // These hooks are useful to preserve player state on player reload + // Firefox doesn't allow hooking of localStorage functions but chrome does + localStorageHookFailed = true; + } + } catch (err) { + console.log('localStorageHooks failed ' + err) + localStorageHookFailed = true; + } + } + declareOptions(window); + hookWindowWorker(); + hookFetch(); + if (PlayerBufferingFix) { + monitorPlayerBuffering(); + } + if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { + onContentLoaded(); + } else { + window.addEventListener("DOMContentLoaded", function() { + onContentLoaded(); + }); + } + window.simulateAds = (depth) => { + if (depth === undefined || depth < 0) { + console.log('Ad depth paramter required (0 = no simulated ad, 1+ = use backup player for given depth)'); + return; + } + postTwitchWorkerMessage('SimulateAds', depth); + }; + window.allSegmentsAreAdSegments = () => { + postTwitchWorkerMessage('AllSegmentsAreAdSegments'); + }; +})(); diff --git a/assets/release-notes.md b/assets/release-notes.md new file mode 100644 index 00000000..6d112234 --- /dev/null +++ b/assets/release-notes.md @@ -0,0 +1,28 @@ +## v5.3.1 + +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 + +### New Features + +- Added a Follow / Unfollow button under the player (between video and chat) for live and offline channels. + +--- + +## v5.2.9 + +October 7, 2026 + +### 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. diff --git a/docs/DESIGN-adblock.md b/docs/DESIGN-adblock.md new file mode 100644 index 00000000..b226d01f --- /dev/null +++ b/docs/DESIGN-adblock.md @@ -0,0 +1,35 @@ +# 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 diff --git a/docs/OBTAINIUM.md b/docs/OBTAINIUM.md new file mode 100644 index 00000000..1a342ef0 --- /dev/null +++ b/docs/OBTAINIUM.md @@ -0,0 +1,45 @@ +# 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. diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 00000000..f9927590 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,57 @@ +# 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`. diff --git a/firebase.json b/firebase.json new file mode 100644 index 00000000..cc241c7f --- /dev/null +++ b/firebase.json @@ -0,0 +1,22 @@ +{ + "flutter": { + "platforms": { + "android": { + "default": { + "projectId": "frosty-128de", + "appId": "1:403282332389:android:a7ff237168bad4c30be20c", + "fileOutput": "android/app/google-services.json" + } + }, + "dart": { + "lib/firebase_options.dart": { + "projectId": "frosty-128de", + "configurations": { + "android": "1:403282332389:android:a7ff237168bad4c30be20c", + "ios": "1:403282332389:ios:0fc3f8c3d4e80c8b0be20c" + } + } + } + } + } +} diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 9625e105..391a902b 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 11.0 diff --git a/ios/Gemfile.lock b/ios/Gemfile.lock index cc851412..7d14f995 100644 --- a/ios/Gemfile.lock +++ b/ios/Gemfile.lock @@ -1,43 +1,49 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.6) - rexml - addressable (2.8.4) - public_suffix (>= 2.0.2, < 6.0) - artifactory (3.0.15) + CFPropertyList (3.0.8) + abbrev (0.1.2) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + artifactory (3.0.17) atomos (0.1.3) - aws-eventstream (1.2.0) - aws-partitions (1.757.0) - aws-sdk-core (3.171.0) - aws-eventstream (~> 1, >= 1.0.2) - aws-partitions (~> 1, >= 1.651.0) - aws-sigv4 (~> 1.5) + aws-eventstream (1.4.0) + aws-partitions (1.1260.0) + aws-sdk-core (3.252.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.63.0) - aws-sdk-core (~> 3, >= 3.165.0) - aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.121.0) - aws-sdk-core (~> 3, >= 3.165.0) + logger + aws-sdk-kms (1.129.0) + aws-sdk-core (~> 3, >= 3.248.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.225.1) + aws-sdk-core (~> 3, >= 3.248.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.4) - aws-sigv4 (1.5.2) + 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) 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.6.4) + digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) - domain_name (0.5.20190701) - unf (>= 0.0.5, < 1.0.0) + domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) - excon (0.99.0) - faraday (1.10.3) + excon (0.112.0) + faraday (1.10.5) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) @@ -49,32 +55,36 @@ GEM faraday-rack (~> 1.0) faraday-retry (~> 1.0) ruby2_keywords (>= 0.0.4) - faraday-cookie_jar (0.0.7) + faraday-cookie_jar (0.0.8) 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.0) + faraday-em_synchrony (1.0.1) faraday-excon (1.1.0) faraday-httpclient (1.0.1) - faraday-multipart (1.0.4) - multipart-post (~> 2) - faraday-net_http (1.0.1) + faraday-multipart (1.2.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.3) - faraday_middleware (1.2.0) + faraday-retry (1.0.4) + faraday_middleware (1.2.1) faraday (~> 1.0) - fastimage (2.2.6) - fastlane (2.212.2) - CFPropertyList (>= 2.3, < 4.0.0) + fastimage (2.4.1) + fastlane (2.234.0) + CFPropertyList (>= 2.3, < 5.0.0) + abbrev (~> 0.1) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) - aws-sdk-s3 (~> 1.0) + aws-sdk-s3 (~> 1.197) babosa (>= 1.0.3, < 2.0.0) - bundler (>= 1.12.0, < 3.0.0) - colored + base64 (~> 0.2) + benchmark (>= 0.1.0) + bundler (>= 1.17.3, < 5.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) @@ -82,136 +92,148 @@ GEM faraday-cookie_jar (~> 0.0.6) faraday_middleware (~> 1.0) fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.1.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-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) + multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3) naturally (~> 2.2) - optparse (~> 0.1.1) + 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.3) + security (= 0.1.5) simctl (~> 1.6.3) terminal-notifier (>= 2.0.0, < 3.0.0) - terminal-table (>= 1.4.5, < 2.0.0) + terminal-table (~> 3) tty-screen (>= 0.6.3, < 1.0.0) tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) xcodeproj (>= 1.13.0, < 2.0.0) - xcpretty (~> 0.3.0) - xcpretty-travis-formatter (>= 0.0.3) + xcpretty (~> 0.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-sirp (1.1.0) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.41.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-core (0.11.0) + google-apis-androidpublisher_v3 (0.102.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (0.18.0) addressable (~> 2.5, >= 2.5.1) - googleauth (>= 0.16.2, < 2.a) - httpclient (>= 2.8.1, < 3.a) + googleauth (~> 1.9) + httpclient (>= 2.8.3, < 3.a) mini_mime (~> 1.0) + mutex_m representable (~> 3.0) retriable (>= 2.0, < 4.a) - rexml - webrick - 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.19.0) - google-apis-core (>= 0.9.0, < 2.a) - google-cloud-core (1.6.0) - google-cloud-env (~> 1.0) + 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) + google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) - google-cloud-env (1.6.0) - faraday (>= 0.17.3, < 3.0) - google-cloud-errors (1.3.1) - google-cloud-storage (1.44.0) + google-cloud-env (2.1.1) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.6.0) + google-cloud-storage (1.61.0) addressable (~> 2.8) digest-crc (~> 0.4) - google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.19.0) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) google-cloud-core (~> 1.6) - googleauth (>= 0.16.2, < 2.a) + googleauth (~> 1.9) mini_mime (~> 1.0) - googleauth (1.5.2) - faraday (>= 0.17.3, < 3.a) + googleauth (1.11.2) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.1) jwt (>= 1.4, < 3.0) - memoist (~> 0.16) multi_json (~> 1.11) os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) highline (2.0.3) - http-cookie (1.0.5) + http-cookie (1.0.8) domain_name (~> 0.5) - httpclient (2.8.3) + httpclient (2.9.0) + mutex_m jmespath (1.6.2) - json (2.6.3) - jwt (2.7.0) - memoist (0.16.2) - mini_magick (4.12.0) - mini_mime (1.1.2) - multi_json (1.15.0) - multipart-post (2.0.0) - nanaimo (0.3.0) - naturally (2.2.1) - optparse (0.1.1) + json (2.19.9) + jwt (2.10.3) + base64 + logger (1.7.0) + mini_magick (4.13.2) + mini_mime (1.1.5) + multi_json (1.21.1) + 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) os (1.1.4) - plist (3.7.0) - public_suffix (5.0.1) - rake (13.0.6) + ostruct (0.6.3) + plist (3.7.2) + public_suffix (7.0.5) + rake (13.4.2) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) - retriable (3.1.2) - rexml (3.2.5) - rouge (2.0.7) + retriable (3.8.0) + rexml (3.4.4) + rouge (3.28.0) ruby2_keywords (0.0.5) - rubyzip (2.3.2) - security (0.1.3) - signet (0.17.0) + rubyzip (2.4.1) + security (0.1.5) + signet (0.22.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) - jwt (>= 1.5, < 3.0) - multi_json (~> 1.10) + jwt (>= 1.5, < 4.0) simctl (1.6.10) CFPropertyList naturally terminal-notifier (2.0.0) - terminal-table (1.8.0) - unicode-display_width (~> 1.1, >= 1.1.1) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) trailblazer-option (0.1.2) tty-cursor (0.7.1) - tty-screen (0.8.1) + tty-screen (0.8.2) tty-spinner (0.9.3) tty-cursor (~> 0.7) uber (0.1.0) - unf (0.1.4) - unf_ext - unf_ext (0.0.8.2) - unicode-display_width (1.8.0) - webrick (1.8.1) + unicode-display_width (2.6.0) word_wrap (1.0.0) - xcodeproj (1.22.0) + xcodeproj (1.27.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) - nanaimo (~> 0.3.0) - rexml (~> 3.2.4) - xcpretty (0.3.0) - rouge (~> 2.0.7) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.1) + rouge (~> 3.28.0) xcpretty-travis-formatter (1.0.1) xcpretty (~> 0.2, >= 0.0.7) PLATFORMS arm64-darwin-21 universal-darwin-22 + universal-darwin-23 + universal-darwin-24 + x86_64-darwin-20 x86_64-darwin-21 + x86_64-linux DEPENDENCIES fastlane diff --git a/ios/Podfile b/ios/Podfile index 88359b22..f17bddc9 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '11.0' +platform :ios, '15.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/ios/Podfile.lock b/ios/Podfile.lock index d5608b35..560b1f09 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,282 +1,28 @@ PODS: - - device_info_plus (0.0.1): + - advanced_in_app_review (0.0.1): - Flutter - - Firebase/Analytics (10.7.0): - - Firebase/Core - - Firebase/Core (10.7.0): - - Firebase/CoreOnly - - FirebaseAnalytics (~> 10.7.0) - - Firebase/CoreOnly (10.7.0): - - FirebaseCore (= 10.7.0) - - Firebase/Crashlytics (10.7.0): - - Firebase/CoreOnly - - FirebaseCrashlytics (~> 10.7.0) - - Firebase/Performance (10.7.0): - - Firebase/CoreOnly - - FirebasePerformance (~> 10.7.0) - - firebase_analytics (10.2.1): - - Firebase/Analytics (= 10.7.0) - - firebase_core - - Flutter - - firebase_core (2.10.0): - - Firebase/CoreOnly (= 10.7.0) - - Flutter - - firebase_crashlytics (3.1.2): - - Firebase/Crashlytics (= 10.7.0) - - firebase_core - - Flutter - - firebase_performance (0.9.1-1): - - Firebase/Performance (= 10.7.0) - - firebase_core - - Flutter - - FirebaseABTesting (10.9.0): - - FirebaseCore (~> 10.0) - - FirebaseAnalytics (10.7.0): - - FirebaseAnalytics/AdIdSupport (= 10.7.0) - - FirebaseCore (~> 10.0) - - FirebaseInstallations (~> 10.0) - - GoogleUtilities/AppDelegateSwizzler (~> 7.8) - - GoogleUtilities/MethodSwizzler (~> 7.8) - - GoogleUtilities/Network (~> 7.8) - - "GoogleUtilities/NSData+zlib (~> 7.8)" - - nanopb (< 2.30910.0, >= 2.30908.0) - - FirebaseAnalytics/AdIdSupport (10.7.0): - - FirebaseCore (~> 10.0) - - FirebaseInstallations (~> 10.0) - - GoogleAppMeasurement (= 10.7.0) - - GoogleUtilities/AppDelegateSwizzler (~> 7.8) - - GoogleUtilities/MethodSwizzler (~> 7.8) - - GoogleUtilities/Network (~> 7.8) - - "GoogleUtilities/NSData+zlib (~> 7.8)" - - nanopb (< 2.30910.0, >= 2.30908.0) - - FirebaseCore (10.7.0): - - FirebaseCoreInternal (~> 10.0) - - GoogleUtilities/Environment (~> 7.8) - - GoogleUtilities/Logger (~> 7.8) - - FirebaseCoreExtension (10.9.0): - - FirebaseCore (~> 10.0) - - FirebaseCoreInternal (10.9.0): - - "GoogleUtilities/NSData+zlib (~> 7.8)" - - FirebaseCrashlytics (10.7.0): - - FirebaseCore (~> 10.5) - - FirebaseInstallations (~> 10.0) - - FirebaseSessions (~> 10.5) - - GoogleDataTransport (~> 9.2) - - GoogleUtilities/Environment (~> 7.8) - - nanopb (< 2.30910.0, >= 2.30908.0) - - PromisesObjC (~> 2.1) - - FirebaseInstallations (10.9.0): - - FirebaseCore (~> 10.0) - - GoogleUtilities/Environment (~> 7.8) - - GoogleUtilities/UserDefaults (~> 7.8) - - PromisesObjC (~> 2.1) - - FirebasePerformance (10.7.0): - - FirebaseCore (~> 10.5) - - FirebaseInstallations (~> 10.0) - - FirebaseRemoteConfig (~> 10.0) - - FirebaseSessions (~> 10.5) - - GoogleDataTransport (~> 9.2) - - GoogleUtilities/Environment (~> 7.8) - - GoogleUtilities/ISASwizzler (~> 7.8) - - GoogleUtilities/MethodSwizzler (~> 7.8) - - nanopb (< 2.30910.0, >= 2.30908.0) - - FirebaseRemoteConfig (10.9.0): - - FirebaseABTesting (~> 10.0) - - FirebaseCore (~> 10.0) - - FirebaseInstallations (~> 10.0) - - GoogleUtilities/Environment (~> 7.8) - - "GoogleUtilities/NSData+zlib (~> 7.8)" - - FirebaseSessions (10.9.0): - - FirebaseCore (~> 10.5) - - FirebaseCoreExtension (~> 10.0) - - FirebaseInstallations (~> 10.0) - - GoogleDataTransport (~> 9.2) - - GoogleUtilities/Environment (~> 7.10) - - nanopb (< 2.30910.0, >= 2.30908.0) - - PromisesSwift (~> 2.1) - Flutter (1.0.0) - - flutter_native_splash (0.0.1): - - Flutter - flutter_secure_storage (6.0.0): - Flutter - - FMDB (2.7.5): - - FMDB/standard (= 2.7.5) - - FMDB/standard (2.7.5) - - GoogleAppMeasurement (10.7.0): - - GoogleAppMeasurement/AdIdSupport (= 10.7.0) - - GoogleUtilities/AppDelegateSwizzler (~> 7.8) - - GoogleUtilities/MethodSwizzler (~> 7.8) - - GoogleUtilities/Network (~> 7.8) - - "GoogleUtilities/NSData+zlib (~> 7.8)" - - nanopb (< 2.30910.0, >= 2.30908.0) - - GoogleAppMeasurement/AdIdSupport (10.7.0): - - GoogleAppMeasurement/WithoutAdIdSupport (= 10.7.0) - - GoogleUtilities/AppDelegateSwizzler (~> 7.8) - - GoogleUtilities/MethodSwizzler (~> 7.8) - - GoogleUtilities/Network (~> 7.8) - - "GoogleUtilities/NSData+zlib (~> 7.8)" - - nanopb (< 2.30910.0, >= 2.30908.0) - - GoogleAppMeasurement/WithoutAdIdSupport (10.7.0): - - GoogleUtilities/AppDelegateSwizzler (~> 7.8) - - GoogleUtilities/MethodSwizzler (~> 7.8) - - GoogleUtilities/Network (~> 7.8) - - "GoogleUtilities/NSData+zlib (~> 7.8)" - - nanopb (< 2.30910.0, >= 2.30908.0) - - GoogleDataTransport (9.2.2): - - GoogleUtilities/Environment (~> 7.7) - - nanopb (< 2.30910.0, >= 2.30908.0) - - PromisesObjC (< 3.0, >= 1.2) - - GoogleUtilities/AppDelegateSwizzler (7.11.1): - - GoogleUtilities/Environment - - GoogleUtilities/Logger - - GoogleUtilities/Network - - GoogleUtilities/Environment (7.11.1): - - PromisesObjC (< 3.0, >= 1.2) - - GoogleUtilities/ISASwizzler (7.11.1) - - GoogleUtilities/Logger (7.11.1): - - GoogleUtilities/Environment - - GoogleUtilities/MethodSwizzler (7.11.1): - - GoogleUtilities/Logger - - GoogleUtilities/Network (7.11.1): - - GoogleUtilities/Logger - - "GoogleUtilities/NSData+zlib" - - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (7.11.1)" - - GoogleUtilities/Reachability (7.11.1): - - GoogleUtilities/Logger - - GoogleUtilities/UserDefaults (7.11.1): - - GoogleUtilities/Logger - - nanopb (2.30909.0): - - nanopb/decode (= 2.30909.0) - - nanopb/encode (= 2.30909.0) - - nanopb/decode (2.30909.0) - - nanopb/encode (2.30909.0) - - package_info_plus (0.4.5): - - Flutter - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - - PromisesObjC (2.2.0) - - PromisesSwift (2.2.0): - - PromisesObjC (= 2.2.0) - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS - - sqflite (0.0.2): - - Flutter - - FMDB (>= 2.7.5) - - url_launcher_ios (0.0.1): - - Flutter - - wakelock (0.0.1): - - Flutter - - webview_flutter_wkwebview (0.0.1): - - Flutter DEPENDENCIES: - - 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`) + - advanced_in_app_review (from `.symlinks/plugins/advanced_in_app_review/ios`) - Flutter (from `Flutter`) - - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/ios`) - - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/ios`) - - sqflite (from `.symlinks/plugins/sqflite/ios`) - - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - - wakelock (from `.symlinks/plugins/wakelock/ios`) - - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`) - -SPEC REPOS: - trunk: - - Firebase - - FirebaseABTesting - - FirebaseAnalytics - - FirebaseCore - - FirebaseCoreExtension - - FirebaseCoreInternal - - FirebaseCrashlytics - - FirebaseInstallations - - FirebasePerformance - - FirebaseRemoteConfig - - FirebaseSessions - - FMDB - - GoogleAppMeasurement - - GoogleDataTransport - - GoogleUtilities - - nanopb - - PromisesObjC - - PromisesSwift EXTERNAL SOURCES: - 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" + advanced_in_app_review: + :path: ".symlinks/plugins/advanced_in_app_review/ios" Flutter: :path: Flutter - flutter_native_splash: - :path: ".symlinks/plugins/flutter_native_splash/ios" flutter_secure_storage: :path: ".symlinks/plugins/flutter_secure_storage/ios" - package_info_plus: - :path: ".symlinks/plugins/package_info_plus/ios" - path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/ios" - shared_preferences_foundation: - :path: ".symlinks/plugins/shared_preferences_foundation/ios" - sqflite: - :path: ".symlinks/plugins/sqflite/ios" - url_launcher_ios: - :path: ".symlinks/plugins/url_launcher_ios/ios" - wakelock: - :path: ".symlinks/plugins/wakelock/ios" - webview_flutter_wkwebview: - :path: ".symlinks/plugins/webview_flutter_wkwebview/ios" SPEC CHECKSUMS: - device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed - Firebase: 0219acf760880eeec8ce479895bd7767466d9f81 - firebase_analytics: e8e294333de66e5429d4aac365966281b4dbfb7d - firebase_core: 18d44f087248303a4a8f05d0099a000c46e3c77a - firebase_crashlytics: 621d91c0c5752e8219f8fe05a4d1e76394f4896a - firebase_performance: 7dd98298ea2f19f480efd59ccbfcad3972ced984 - FirebaseABTesting: 005b70969e2817e2a1e631e8dba29134a04c0622 - FirebaseAnalytics: f8133442ee6f8512e28ff19e62ce15398bfaeace - FirebaseCore: e317665b9d744727a97e623edbbed009320afdd7 - FirebaseCoreExtension: d3e9bba2930a8033042112397cd9f006a1bb203d - FirebaseCoreInternal: d2b4acb827908e72eca47a9fd896767c3053921e - FirebaseCrashlytics: 35fdd1a433b31e28adcf5c8933f4c526691a1e0b - FirebaseInstallations: c58489c9caacdbf27d1da60891a87318e20218e0 - FirebasePerformance: 8281bbaf08aad194001018b932115b7d58a6f00b - FirebaseRemoteConfig: 5ea5834e8c518f377bf1af2d97ebd611914ebf2d - FirebaseSessions: 44a6782502eb279a214d4adca20891353278760c - Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 - flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef - flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be - FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a - GoogleAppMeasurement: fe17c92a32207dd5cdd4e8d742767f2da74857f6 - GoogleDataTransport: 8378d1fa8ac49753ea6ce70d65a7cb70ce5f66e6 - GoogleUtilities: 9aa0ad5a7bc171f8bae016300bfcfa3fb8425749 - nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431 - package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e - path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852 - PromisesObjC: 09985d6d70fbe7878040aa746d78236e6946d2ef - PromisesSwift: cf9eb58666a43bbe007302226e510b16c1e10959 - shared_preferences_foundation: 297b3ebca31b34ec92be11acd7fb0ba932c822ca - sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 - url_launcher_ios: ae1517e5e344f5544fb090b079e11f399dfbe4d2 - wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f - webview_flutter_wkwebview: b7e70ef1ddded7e69c796c7390ee74180182971f + advanced_in_app_review: daebe4c801525b756fe589de60fd327cb12f965c + Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47 + flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 -PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3 +PODFILE CHECKSUM: 57c8aed26fba39d3ec9424816221f294a07c58eb -COCOAPODS: 1.11.3 +COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 519ca847..c83d966a 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -11,10 +11,13 @@ 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 */ @@ -37,6 +40,7 @@ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieExtractorPlugin.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 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 = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; @@ -48,7 +52,9 @@ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A68609F67C30CFEF267CD4D3 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = ""; }; 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 = ""; }; + D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -56,6 +62,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, 3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -66,6 +73,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -104,7 +112,9 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */, ); path = Runner; sourceTree = ""; @@ -131,6 +141,9 @@ /* Begin PBXNativeTarget section */ 97C146ED1CF9000F007C117D /* Runner */ = { + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( @@ -157,9 +170,12 @@ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); isa = PBXProject; attributes = { - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -196,6 +212,7 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, BE8095795EAFF58C08A43B48 /* GoogleService-Info.plist in Resources */, + D4F5A60712340001ABCD1234 /* PrivacyInfo.xcprivacy in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -242,7 +259,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"$PODS_ROOT/FirebaseCrashlytics/upload-symbols\" --flutter-project \"$PROJECT_DIR/firebase_app_id_file.json\" "; + 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"; }; 34CC65D58A1948B306772884 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; @@ -268,6 +285,7 @@ files = ( ); inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( @@ -299,6 +317,7 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + F1C00CE01A2B3C4D00E5F6A8 /* CookieExtractorPlugin.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -366,7 +385,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -380,6 +399,7 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; @@ -447,7 +467,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -496,7 +516,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -512,6 +532,7 @@ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; @@ -538,6 +559,7 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; @@ -582,6 +604,18 @@ 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 */; } diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..6839f6d5 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,122 @@ +{ + "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 +} diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index c87d15a3..5db441f5 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,10 +1,28 @@ + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..6839f6d5 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,122 @@ +{ + "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 +} diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 70693e4a..a51f2738 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,13 +1,24 @@ -import UIKit -import Flutter - -@UIApplicationMain -@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) - } -} +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + 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) + } +} + diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png index 65e45c48..0c07f1fd 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png index b45507c1..83084f78 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png index d56cc8fd..604f87bf 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/CookieExtractorPlugin.swift b/ios/Runner/CookieExtractorPlugin.swift new file mode 100644 index 00000000..24495865 --- /dev/null +++ b/ios/Runner/CookieExtractorPlugin.swift @@ -0,0 +1,27 @@ +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) + } + } +} diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 2f78432e..1f85dcd5 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -48,13 +48,36 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - UIViewControllerBasedStatusBarAppearance - - UIStatusBarHidden - CADisableMinimumFrameDurationOnPhone UIApplicationSupportsIndirectInputEvents - - + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneDelegateClassName + FlutterSceneDelegate + UISceneConfigurationName + flutter + UISceneStoryboardFile + Main + + + + + UIStatusBarHidden + + UIBackgroundModes + + audio + + diff --git a/ios/Runner/PrivacyInfo.xcprivacy b/ios/Runner/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..a34b7e2e --- /dev/null +++ b/ios/Runner/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyTrackingDomains + + NSPrivacyAccessedAPITypes + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index 78bcb074..df241cb0 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -17,7 +17,9 @@ default_platform(:ios) platform :ios do desc 'Push a new release build to the App Store' - lane :release do + lane :release do |options| + dry_run = options[:dry_run] || false + # Setup for CI. Uses a keychain to prevent the CI from hanging due to password prompts. setup_ci @@ -29,8 +31,9 @@ platform :ios do is_key_content_base64: true ) - # Manage code signing (with match). - sync_code_signing + # 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) # Automatic signing is used during development, but fastlane needs to use manual signing. update_code_signing_settings( @@ -45,6 +48,11 @@ 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, diff --git a/ios/fastlane/metadata/copyright.txt b/ios/fastlane/metadata/copyright.txt index 37d72ff1..1967ce48 100644 --- a/ios/fastlane/metadata/copyright.txt +++ b/ios/fastlane/metadata/copyright.txt @@ -1 +1 @@ -2023 Tommy Chow +2026 Tommy Chow diff --git a/ios/fastlane/metadata/en-US/name.txt b/ios/fastlane/metadata/en-US/name.txt index 533e8f10..445c1415 100644 --- a/ios/fastlane/metadata/en-US/name.txt +++ b/ios/fastlane/metadata/en-US/name.txt @@ -1 +1 @@ -Frosty for Twitch +Frosty diff --git a/ios/fastlane/metadata/en-US/privacy_url.txt b/ios/fastlane/metadata/en-US/privacy_url.txt index 1bd5f1a2..4350465a 100644 --- a/ios/fastlane/metadata/en-US/privacy_url.txt +++ b/ios/fastlane/metadata/en-US/privacy_url.txt @@ -1 +1 @@ -https://www.privacypolicies.com/live/095b4264-31b1-4280-837e-5843abde8b16 +https://frostyapp.io/privacy diff --git a/ios/fastlane/metadata/en-US/promotional_text.txt b/ios/fastlane/metadata/en-US/promotional_text.txt index e633a6b1..cf35ecf1 100644 --- a/ios/fastlane/metadata/en-US/promotional_text.txt +++ b/ios/fastlane/metadata/en-US/promotional_text.txt @@ -1 +1 @@ -Watch and chat on Twitch with your favorite emotes from 7TV, BTTV, and FFZ! \ No newline at end of file +Watch live streams and see community emotes in chat. diff --git a/ios/fastlane/metadata/en-US/release_notes.txt b/ios/fastlane/metadata/en-US/release_notes.txt index 87946d58..37b5ab1f 100644 --- a/ios/fastlane/metadata/en-US/release_notes.txt +++ b/ios/fastlane/metadata/en-US/release_notes.txt @@ -1,2 +1 @@ -Bug Fixes -• Fixed chatters not sorted alphabetically \ No newline at end of file +See full release notes at https://github.com/tommyxchow/frosty/releases \ No newline at end of file diff --git a/ios/fastlane/metadata/en-US/subtitle.txt b/ios/fastlane/metadata/en-US/subtitle.txt index 0712b6dc..8c1195c5 100644 --- a/ios/fastlane/metadata/en-US/subtitle.txt +++ b/ios/fastlane/metadata/en-US/subtitle.txt @@ -1 +1 @@ -See 7TV, BTTV, and FFZ emotes! +Community emotes in chat diff --git a/lib/apis/base_api_client.dart b/lib/apis/base_api_client.dart new file mode 100644 index 00000000..87163d84 --- /dev/null +++ b/lib/apis/base_api_client.dart @@ -0,0 +1,269 @@ +import 'package:dio/dio.dart'; +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; + +/// Common type aliases to reduce repetition +typedef JsonMap = Map; +typedef JsonList = List; +typedef QueryParams = Map; +typedef ApiHeaders = Map; + +/// Custom API exceptions for better error handling +class ApiException implements Exception { + final String message; + final int? statusCode; + final DioExceptionType? type; + + const ApiException(this.message, [this.statusCode, this.type]); + + @override + String toString() => 'ApiException: $message'; +} + +class NetworkException extends ApiException { + const NetworkException(String message) + : super(message, null, DioExceptionType.connectionError); +} + +class TimeoutException extends ApiException { + const TimeoutException(String message) + : super(message, null, DioExceptionType.connectionTimeout); +} + +class ServerException extends ApiException { + const ServerException(String message, int statusCode) + : super(message, statusCode, DioExceptionType.badResponse); +} + +class NotFoundException extends ApiException { + const NotFoundException(String message) + : super(message, 404, DioExceptionType.badResponse); +} + +class UnauthorizedException extends ApiException { + const UnauthorizedException(String message) + : super(message, 401, DioExceptionType.badResponse); +} + +/// Twitch GQL rejected the request for a missing/invalid Client-Integrity token. +class IntegrityCheckException extends ApiException { + const IntegrityCheckException([ + String message = 'Twitch integrity check failed', + ]) : super(message); +} + +/// Base API client to eliminate code duplication across API services +abstract class BaseApiClient { + final Dio _dio; + final String baseUrl; + + const BaseApiClient(this._dio, this.baseUrl); + + /// Builds the full URL, handling both relative endpoints and full URLs + String _buildUrl(String endpoint) { + // If endpoint is already a full URL (starts with http:// or https://), use it as-is + if (endpoint.startsWith('http://') || endpoint.startsWith('https://')) { + return endpoint; + } + // Otherwise, concatenate with base URL + return '$baseUrl$endpoint'; + } + + /// Generic GET request with common error handling + Future get( + String endpoint, { + QueryParams? queryParameters, + ApiHeaders? headers, + CancelToken? cancelToken, + }) async { + try { + final url = _buildUrl(endpoint); + final response = await _dio.get( + url, + queryParameters: queryParameters, + options: Options(headers: headers), + cancelToken: cancelToken, + ); + return response.data as T; + } on DioException catch (e) { + throw _handleError(e); + } + } + + /// Generic POST request with common error handling + Future post( + String endpoint, { + dynamic data, + QueryParams? queryParameters, + ApiHeaders? headers, + CancelToken? cancelToken, + }) async { + try { + final url = _buildUrl(endpoint); + final response = await _dio.post( + url, + data: data, + queryParameters: queryParameters, + options: Options(headers: headers), + cancelToken: cancelToken, + ); + return response.data as T; + } on DioException catch (e) { + throw _handleError(e); + } + } + + /// Generic PUT request with common error handling + Future put( + String endpoint, { + dynamic data, + QueryParams? queryParameters, + ApiHeaders? headers, + CancelToken? cancelToken, + }) async { + try { + final url = _buildUrl(endpoint); + final response = await _dio.put( + url, + data: data, + queryParameters: queryParameters, + options: Options(headers: headers), + cancelToken: cancelToken, + ); + return response.data as T; + } on DioException catch (e) { + throw _handleError(e); + } + } + + /// Generic DELETE request with common error handling + Future delete( + String endpoint, { + QueryParams? queryParameters, + ApiHeaders? headers, + CancelToken? cancelToken, + }) async { + try { + final url = _buildUrl(endpoint); + final response = await _dio.delete( + url, + queryParameters: queryParameters, + options: Options(headers: headers), + cancelToken: cancelToken, + ); + return response.data as T; + } on DioException catch (e) { + throw _handleError(e); + } + } + + /// Check if response indicates success (useful for status-only operations) + bool isSuccessResponse(Response response) { + return response.statusCode != null && + response.statusCode! >= 200 && + response.statusCode! < 300; + } + + /// Converts DioException to appropriate ApiException subtype + ApiException _handleError(DioException error) { + final exception = switch (error.type) { + DioExceptionType.connectionTimeout || + DioExceptionType.sendTimeout || + DioExceptionType.receiveTimeout || + DioExceptionType.transformTimeout => + TimeoutException(_getTimeoutMessage(error.type)), + DioExceptionType.connectionError => NetworkException( + 'No internet connection. Please check your network.', + ), + DioExceptionType.badResponse => _handleHttpError(error), + DioExceptionType.cancel => ApiException('Request was cancelled'), + DioExceptionType.badCertificate => + ApiException('Security certificate error'), + DioExceptionType.unknown => + ApiException(error.message ?? 'An unexpected error occurred'), + }; + + // Record server errors and unexpected failures to Crashlytics. + // Skip timeouts, network errors (normal offline conditions), 401s, and cancellations. + if (exception is ServerException || + error.type == DioExceptionType.unknown || + error.type == DioExceptionType.badCertificate) { + try { + FirebaseCrashlytics.instance.recordError( + exception, + error.stackTrace, + reason: '${error.requestOptions.method} ${error.requestOptions.uri}', + ); + } catch (_) { + // Firebase may not be initialized (e.g., in tests). + } + } + + return exception; + } + + /// Gets specific timeout message based on timeout type + String _getTimeoutMessage(DioExceptionType type) { + switch (type) { + case DioExceptionType.connectionTimeout: + return 'Connection timeout. Please try again.'; + case DioExceptionType.sendTimeout: + return 'Upload timeout. Please try again.'; + case DioExceptionType.receiveTimeout: + return 'Server response timeout. Please try again.'; + case DioExceptionType.transformTimeout: + return 'Response processing timeout. Please try again.'; + default: + return 'Request timeout. Please try again.'; + } + } + + /// Handles HTTP status code errors with specific exception types + ApiException _handleHttpError(DioException error) { + final statusCode = error.response?.statusCode; + final responseData = error.response?.data; + + // Extract error message from server response + String? serverMessage; + if (responseData is JsonMap) { + serverMessage = + responseData['message'] as String? ?? + responseData['error'] as String? ?? + responseData['error_description'] as String?; + } + + switch (statusCode) { + case 400: + return ApiException(serverMessage ?? 'Invalid request data'); + case 401: + return UnauthorizedException('Please log in to continue'); + case 403: + return ApiException('Access denied. Insufficient permissions'); + case 404: + return NotFoundException(serverMessage ?? 'Resource not found'); + case 409: + return ApiException('Conflict with current state. Please refresh'); + case 422: + return ApiException(serverMessage ?? 'Invalid data provided'); + case 429: + return ApiException( + 'Rate limit exceeded. Please wait before trying again', + ); + case 500: + return ServerException('Server error. Please try again later', 500); + case 502: + return ServerException('Service temporarily unavailable', 502); + case 503: + return ServerException( + 'Service maintenance. Please try again later', + 503, + ); + case 504: + return ServerException('Server timeout. Please try again', 504); + default: + return ServerException( + serverMessage ?? 'HTTP Error $statusCode occurred', + statusCode ?? 500, + ); + } + } +} diff --git a/lib/apis/bttv_api.dart b/lib/apis/bttv_api.dart index e4507a1d..e65da2a2 100644 --- a/lib/apis/bttv_api.dart +++ b/lib/apis/bttv_api.dart @@ -1,71 +1,55 @@ -import 'dart:convert'; - +import 'package:dio/dio.dart'; +import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/models/badges.dart'; import 'package:frosty/models/emotes.dart'; -import 'package:http/http.dart'; /// The BTTV service for making API calls. -class BTTVApi { - final Client _client; - - const BTTVApi(this._client); +class BTTVApi extends BaseApiClient { + BTTVApi(Dio dio) : super(dio, 'https://api.betterttv.net/3/cached'); /// Returns a map of global BTTV emotes to their URL. Future> getEmotesGlobal() async { - final url = Uri.parse('https://api.betterttv.net/3/cached/emotes/global'); + final data = await get('/emotes/global'); - final response = await _client.get(url); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body) as List; - final emotes = decoded.map((emote) => EmoteBTTV.fromJson(emote)).toList(); + final emotes = data.map((emote) => EmoteBTTV.fromJson(emote)).toList(); - return emotes - .map((emote) => Emote.fromBTTV(emote, EmoteType.bttvGlobal)) - .toList(); - } else { - return Future.error('Failed to get BTTV global emotes'); - } + return emotes + .map((emote) => Emote.fromBTTV(emote, EmoteType.bttvGlobal)) + .toList(); } /// Returns a map of a channel's BTTV emotes to their URL. Future> getEmotesChannel({required String id}) async { - final url = - Uri.parse('https://api.betterttv.net/3/cached/users/twitch/$id'); + final data = await get('/users/twitch/$id'); - final response = await _client.get(url); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body); - final result = EmoteBTTVChannel.fromJson(decoded); + final result = EmoteBTTVChannel.fromJson(data); - final emoteToUrl = []; - emoteToUrl.addAll(result.channelEmotes - .map((emote) => Emote.fromBTTV(emote, EmoteType.bttvChannel))); - emoteToUrl.addAll(result.sharedEmotes - .map((emote) => Emote.fromBTTV(emote, EmoteType.bttvShared))); + final emoteToUrl = []; + emoteToUrl.addAll( + result.channelEmotes.map( + (emote) => Emote.fromBTTV(emote, EmoteType.bttvChannel), + ), + ); + emoteToUrl.addAll( + result.sharedEmotes.map( + (emote) => Emote.fromBTTV(emote, EmoteType.bttvShared), + ), + ); - return emoteToUrl; - } else { - return Future.error('Failed to get BTTV channel emotes'); - } + return emoteToUrl; } /// Returns a map of username to their BTTV badge. Future> getBadges() async { - final url = Uri.parse('https://api.betterttv.net/3/cached/badges'); + final data = await get('/badges'); - final response = await _client.get(url); - if (response.statusCode == 200) { - final badges = jsonDecode(response.body) as List; + final badgeObjects = data + .map((badge) => BadgeInfoBTTV.fromJson(badge)) + .toList(); - final badgeObjects = - badges.map((badge) => BadgeInfoBTTV.fromJson(badge)).toList(); - - return { - for (final badge in badgeObjects) - badge.providerId: ChatBadge.fromBTTV(badge) - }; - } else { - return Future.error('Failed to get BTTV badges'); - } + return { + for (final badge in badgeObjects) + badge.providerId: ChatBadge.fromBTTV(badge), + }; } } diff --git a/lib/apis/dio_client.dart b/lib/apis/dio_client.dart new file mode 100644 index 00000000..1ba5e538 --- /dev/null +++ b/lib/apis/dio_client.dart @@ -0,0 +1,71 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:frosty/apis/firebase_performance_interceptor.dart'; + +/// Centralized Dio client configuration with interceptors and error handling +class DioClient { + static Dio createClient({List? additionalInterceptors}) { + final dio = Dio(); + + // Optimized configuration based on 2024-2025 best practices + dio.options = BaseOptions( + connectTimeout: const Duration( + seconds: 8, + ), // Balanced for various networks + receiveTimeout: const Duration( + seconds: 15, + ), // Allow for larger API responses + sendTimeout: const Duration(seconds: 10), // Reasonable for uploads + headers: { + 'User-Agent': 'Frosty (Flutter Twitch Client)', + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Connection': 'keep-alive', // Enable connection reuse + }, + followRedirects: true, + maxRedirects: 3, // Reduced for better performance + persistentConnection: true, // Enable connection pooling + ); + + // Simple logging for debug builds + if (kDebugMode) { + dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) { + debugPrint('→ ${options.method} ${options.uri}'); + handler.next(options); + }, + onResponse: (response, handler) { + final statusCode = response.statusCode ?? 0; + final emoji = statusCode >= 200 && statusCode < 300 ? '✅' : '❌'; + debugPrint( + '$emoji ${response.statusCode} ${response.requestOptions.method} ${response.requestOptions.uri}', + ); + handler.next(response); + }, + onError: (error, handler) { + debugPrint( + '❌ ${error.type.name} ${error.requestOptions.method} ${error.requestOptions.uri}', + ); + if (error.response != null) { + debugPrint(' Status: ${error.response!.statusCode}'); + } + handler.next(error); + }, + ), + ); + } + + // Trace HTTP requests as Firebase Performance metrics + dio.interceptors.add(FirebasePerformanceInterceptor()); + + // Add any additional interceptors provided + if (additionalInterceptors != null) { + for (final interceptor in additionalInterceptors) { + dio.interceptors.add(interceptor); + } + } + + return dio; + } +} diff --git a/lib/apis/ffz_api.dart b/lib/apis/ffz_api.dart index e59a21e1..32e70d19 100644 --- a/lib/apis/ffz_api.dart +++ b/lib/apis/ffz_api.dart @@ -1,90 +1,69 @@ -import 'dart:convert'; - +import 'package:dio/dio.dart'; +import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/models/badges.dart'; import 'package:frosty/models/emotes.dart'; -import 'package:http/http.dart'; -import 'package:tuple/tuple.dart'; /// The FFZ service for making API calls. -class FFZApi { - final Client _client; - - const FFZApi(this._client); +class FFZApi extends BaseApiClient { + FFZApi(Dio dio) : super(dio, 'https://api.frankerfacez.com/v1'); /// Returns a map of global FFZ emotes to their URL. Future> getEmotesGlobal() async { - final url = Uri.parse('https://api.frankerfacez.com/v1/set/global'); + final data = await get('/set/global'); - final response = await _client.get(url); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body); - final defaultSets = decoded['default_sets'] as List; + final defaultSets = data['default_sets'] as JsonList; - final emotes = []; - for (final setId in defaultSets) { - final emoticons = - decoded['sets'][setId.toString()]['emoticons'] as List; - emotes.addAll(emoticons.map((emote) => EmoteFFZ.fromJson(emote))); - } - - return emotes - .map((emote) => Emote.fromFFZ(emote, EmoteType.ffzGlobal)) - .toList(); - } else { - return Future.error('Failed to get FFZ global emotes'); + final emotes = []; + for (final setId in defaultSets) { + final emoticons = data['sets'][setId.toString()]['emoticons'] as JsonList; + emotes.addAll(emoticons.map((emote) => EmoteFFZ.fromJson(emote))); } + + return emotes + .map((emote) => Emote.fromFFZ(emote, EmoteType.ffzGlobal)) + .toList(); } /// Returns a channel's FFZ room info including custom badges and emote used. - Future>> getRoomInfo({required String id}) async { - final url = Uri.parse('https://api.frankerfacez.com/v1/room/id/$id'); + Future<(RoomFFZ, List)> getRoomInfo({required String id}) async { + final data = await get('/room/id/$id'); - final response = await _client.get(url); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body); - final roomInfo = RoomFFZ.fromJson(decoded['room']); - final emoticons = - decoded['sets'][roomInfo.set.toString()]['emoticons'] as List; + final roomInfo = RoomFFZ.fromJson(data['room']); + final emoticons = + data['sets'][roomInfo.set.toString()]['emoticons'] as JsonList; - final emotes = emoticons.map((emote) => EmoteFFZ.fromJson(emote)); + final emotes = emoticons.map((emote) => EmoteFFZ.fromJson(emote)); - return Tuple2( - roomInfo, - emotes - .map((emote) => Emote.fromFFZ(emote, EmoteType.ffzChannel)) - .toList()); - } else { - return Future.error('Failed to get FFZ room info'); - } + return ( + roomInfo, + emotes + .map((emote) => Emote.fromFFZ(emote, EmoteType.ffzChannel)) + .toList(), + ); } - /// Returns a map of badges user IDs to a list of their FFZ badges. + /// Returns a map of user IDs to a list of their FFZ badges. Future>> getBadges() async { - final url = Uri.parse('https://api.frankerfacez.com/v1/badges/ids'); + final data = await get('/badges/ids'); - final response = await _client.get(url); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body); - final badges = decoded['badges'] as List; - final badgeObjects = - badges.map((badge) => BadgeInfoFFZ.fromJson(badge)).toList(); + final badges = data['badges'] as JsonList; + final badgeObjects = badges + .map((badge) => BadgeInfoFFZ.fromJson(badge)) + .toList(); - final result = >{}; - for (final badge in badgeObjects.reversed) { - for (final userId in decoded['users'][badge.id.toString()]) { - final entry = result[userId.toString()]; - final normalBadge = ChatBadge.fromFFZ(badge); - if (entry == null) { - result[userId.toString()] = [normalBadge]; - } else { - entry.add(normalBadge); - } + final result = >{}; + for (final badge in badgeObjects.reversed) { + for (final userId in data['users'][badge.id.toString()]) { + final entry = result[userId.toString()]; + final normalBadge = ChatBadge.fromFFZ(badge); + if (entry == null) { + result[userId.toString()] = [normalBadge]; + } else { + entry.add(normalBadge); } } - - return result; - } else { - return Future.error('Failed to get FFZ badges'); } + + return result; } } diff --git a/lib/apis/firebase_performance_interceptor.dart b/lib/apis/firebase_performance_interceptor.dart new file mode 100644 index 00000000..f1c6712b --- /dev/null +++ b/lib/apis/firebase_performance_interceptor.dart @@ -0,0 +1,46 @@ +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, + }; +} diff --git a/lib/apis/seventv_api.dart b/lib/apis/seventv_api.dart index e772cee5..27d02a0f 100644 --- a/lib/apis/seventv_api.dart +++ b/lib/apis/seventv_api.dart @@ -1,75 +1,39 @@ -import 'dart:convert'; - -import 'package:frosty/models/badges.dart'; +import 'package:dio/dio.dart'; +import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/models/emotes.dart'; -import 'package:http/http.dart'; /// The 7TV service for making API calls. -class SevenTVApi { - final Client _client; - - const SevenTVApi(this._client); +class SevenTVApi extends BaseApiClient { + SevenTVApi(Dio dio) : super(dio, 'https://7tv.io/v3'); /// Returns a map of global 7TV emotes to their URL. Future> getEmotesGlobal() async { - final url = Uri.parse('https://api.7tv.app/v2/emotes/global'); + final data = await get('/emote-sets/global'); - final response = await _client.get(url); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body) as List; - final emotes = decoded.map((emote) => Emote7TV.fromJson(emote)); + final decoded = data['emotes'] as JsonList; + final emotes = decoded.map((emote) => Emote7TV.fromJson(emote)); - return emotes - .map((emote) => Emote.from7TV(emote, EmoteType.sevenTVGlobal)) - .toList(); - } else { - return Future.error('Failed to get 7TV global emotes'); - } + return emotes + .map((emote) => Emote.from7TV(emote, EmoteType.sevenTVGlobal)) + .toList(); } - /// Returns a map of a channel's 7TV emotes to their URL. - Future> getEmotesChannel({required String id}) async { - final url = Uri.parse('https://api.7tv.app/v2/users/$id/emotes'); + /// Returns a tuple containing the emote set ID and a map of a channel's 7TV + /// emotes to their URL. + Future<(String, List)> getEmotesChannel({required String id}) async { + final data = await get('/users/twitch/$id'); - final response = await _client.get(url); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body) as List; - final emotes = decoded.map((emote) => Emote7TV.fromJson(emote)); + final emoteSetId = data['emote_set']['id'] as String; + final emotes = (data['emote_set']['emotes'] as JsonList).map( + (emote) => Emote7TV.fromJson(emote), + ); - return emotes + return ( + emoteSetId, + emotes .map((emote) => Emote.from7TV(emote, EmoteType.sevenTVChannel)) - .toList(); - } else { - return Future.error('Failed to get 7TV channel emotes'); - } - } - - /// Returns a map of user IDS to a list of their 7TV badges. - Future>> getBadges() async { - final url = - Uri.parse('https://api.7tv.app/v2/badges?user_identifier=twitch_id'); - - final response = await _client.get(url); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body)['badges'] as List; - final badges = decoded.map((emote) => BadgeInfo7TV.fromJson(emote)); - - final result = >{}; - for (final badge in badges) { - for (final userId in badge.users) { - final entry = result[userId]; - final normalBadge = ChatBadge.from7TV(badge); - if (entry == null) { - result[userId] = [normalBadge]; - } else { - entry.add(normalBadge); - } - } - } - - return result; - } else { - return Future.error('Failed to get 7TV badges'); - } + .where((emote) => emote.url.isNotEmpty) + .toList(), + ); } } diff --git a/lib/apis/twitch_ad_solutions.dart b/lib/apis/twitch_ad_solutions.dart new file mode 100644 index 00000000..1647f4f0 --- /dev/null +++ b/lib/apis/twitch_ad_solutions.dart @@ -0,0 +1,163 @@ +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 = [ + '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 _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'; diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index 9c653970..f3cc787d 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -1,176 +1,140 @@ -import 'dart:convert'; - +import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; +import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/models/badges.dart'; import 'package:frosty/models/category.dart'; import 'package:frosty/models/channel.dart'; import 'package:frosty/models/emotes.dart'; +import 'package:frosty/models/followed_channel.dart'; +import 'package:frosty/models/shared_chat_session.dart'; import 'package:frosty/models/stream.dart'; import 'package:frosty/models/user.dart'; -import 'package:http/http.dart'; /// The Twitch service for making API calls. -class TwitchApi { - final Client _client; +class TwitchApi extends BaseApiClient { + static const String _helixBaseUrl = 'https://api.twitch.tv/helix'; + static const String _oauthBaseUrl = 'https://id.twitch.tv/oauth2'; + static const String _recentMessagesUrl = + 'https://recent-messages.robotty.de/api/v2'; - const TwitchApi(this._client); + TwitchApi(Dio dio) : super(dio, _helixBaseUrl); /// Returns a list of all Twitch global emotes. - Future> getEmotesGlobal( - {required Map headers}) async { - final url = Uri.parse('https://api.twitch.tv/helix/chat/emotes/global'); - final response = await _client.get(url, headers: headers); + Future> getEmotesGlobal() async { + final data = await get('/chat/emotes/global'); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body)['data'] as List; - final emotes = - decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList(); + final decoded = data['data'] as JsonList; + final emotes = decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList(); - return emotes - .map((emote) => Emote.fromTwitch(emote, EmoteType.twitchGlobal)) - .toList(); - } else { - return Future.error('Failed to get Twitch global emotes'); - } + return emotes + .map((emote) => Emote.fromTwitch(emote, EmoteType.twitchGlobal)) + .toList(); } /// Returns a list of a channel's Twitch emotes given their [id]. - Future> getEmotesChannel({ - required String id, - required Map headers, - }) async { - final url = - Uri.parse('https://api.twitch.tv/helix/chat/emotes?broadcaster_id=$id'); + Future> getEmotesChannel({required String id}) async { + final data = await get( + '/chat/emotes', + queryParameters: {'broadcaster_id': id}, + ); - final response = await _client.get(url, headers: headers); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body)['data'] as List; - final emotes = - decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList(); + final decoded = data['data'] as JsonList; + final emotes = decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList(); - return emotes.map((emote) { - switch (emote.emoteType) { - case 'bitstier': - return Emote.fromTwitch(emote, EmoteType.twitchBits); - case 'follower': - return Emote.fromTwitch(emote, EmoteType.twitchFollower); - case 'subscriptions': - return Emote.fromTwitch(emote, EmoteType.twitchChannel); - default: - return Emote.fromTwitch(emote, EmoteType.twitchChannel); - } - }).toList(); - } else { - return Future.error('Failed to get Twitch channel emotes'); - } + return emotes.map((emote) { + switch (emote.emoteType) { + case 'bitstier': + return Emote.fromTwitch(emote, EmoteType.twitchBits); + case 'follower': + return Emote.fromTwitch(emote, EmoteType.twitchFollower); + case 'subscriptions': + return Emote.fromTwitch(emote, EmoteType.twitchChannel); + default: + return Emote.fromTwitch(emote, EmoteType.twitchChannel); + } + }).toList(); } /// Returns a list of Twitch emotes under the provided [setId]. - Future> getEmotesSets({ - required String setId, - required Map headers, - }) async { - final url = Uri.parse( - 'https://api.twitch.tv/helix/chat/emotes/set?emote_set_id=$setId'); + Future> getEmotesSets({required String setId}) async { + final data = await get( + '/chat/emotes/set', + queryParameters: {'emote_set_id': setId}, + ); - final response = await _client.get(url, headers: headers); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body)['data'] as List; - final emotes = - decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList(); + final decoded = data['data'] as JsonList; + final emotes = decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList(); - return emotes.map((emote) { - switch (emote.emoteType) { - case 'globals': - case 'smilies': - return Emote.fromTwitch(emote, EmoteType.twitchGlobal); - case 'subscriptions': - return Emote.fromTwitch(emote, EmoteType.twitchSub); - default: - return Emote.fromTwitch(emote, EmoteType.twitchUnlocked); - } - }).toList(); - } else { - return Future.error('Failed to get Twitch emotes set'); - } + return emotes.map((emote) { + switch (emote.emoteType) { + case 'globals': + case 'smilies': + return Emote.fromTwitch(emote, EmoteType.twitchGlobal); + case 'subscriptions': + return Emote.fromTwitch(emote, EmoteType.twitchSub); + default: + return Emote.fromTwitch(emote, EmoteType.twitchUnlocked); + } + }).toList(); } /// Returns a map of global Twitch badges to their [Emote] object. - Future> getBadgesGlobal( - {required Map headers}) async { - final url = Uri.parse('https://api.twitch.tv/helix/chat/badges/global'); + Future> getBadgesGlobal() async { + final data = await get('/chat/badges/global'); - final response = await _client.get(url, headers: headers); - if (response.statusCode == 200) { - final result = {}; - final decoded = jsonDecode(response.body)['data'] as List; + final result = {}; + final decoded = data['data'] as JsonList; - for (final badge in decoded) { - final id = badge['set_id'] as String; - final versions = badge['versions'] as List; + for (final badge in decoded) { + final id = badge['set_id'] as String; + final versions = badge['versions'] as JsonList; - for (final version in versions) { - final badgeInfo = BadgeInfoTwitch.fromJson(version); - result['$id/${badgeInfo.id}'] = ChatBadge.fromTwitch(badgeInfo); - } + for (final version in versions) { + final badgeInfo = BadgeInfoTwitch.fromJson(version); + result['$id/${badgeInfo.id}'] = ChatBadge.fromTwitch(badgeInfo); } - - return result; - } else { - return Future.error('Failed to get Twitch global badges'); } + + return result; } /// Returns a map of a channel's Twitch badges to their [Emote] object. - Future> getBadgesChannel({ - required String id, - required Map headers, - }) async { - final url = - Uri.parse('https://api.twitch.tv/helix/chat/badges?broadcaster_id=$id'); + Future> getBadgesChannel({required String id}) async { + final data = await get( + '/chat/badges', + queryParameters: {'broadcaster_id': id}, + ); - final response = await _client.get(url, headers: headers); - if (response.statusCode == 200) { - final result = {}; - final decoded = jsonDecode(response.body)['data'] as List; + final result = {}; + final decoded = data['data'] as JsonList; - for (final badge in decoded) { - final id = badge['set_id'] as String; - final versions = badge['versions'] as List; + for (final badge in decoded) { + final id = badge['set_id'] as String; + final versions = badge['versions'] as JsonList; - for (final version in versions) { - final badgeInfo = BadgeInfoTwitch.fromJson(version); - result['$id/${badgeInfo.id}'] = ChatBadge.fromTwitch(badgeInfo); - } + for (final version in versions) { + final badgeInfo = BadgeInfoTwitch.fromJson(version); + result['$id/${badgeInfo.id}'] = ChatBadge.fromTwitch(badgeInfo); } - - return result; - } else { - return Future.error('Failed to get Twitch channel badges'); } + + return result; } - /// Returns the user's info given their token through [headers]. - Future getUserInfo({required Map headers}) async { - final url = Uri.parse('https://api.twitch.tv/helix/users'); + /// Returns the user's info given their token. + Future getUserInfo() async { + final data = await get('/users'); - final response = await _client.get(url, headers: headers); - if (response.statusCode == 200) { - final userData = jsonDecode(response.body)['data'] as List; - - return UserTwitch.fromJson(userData.first); - } else { - return Future.error('Failed to get Twitch user info'); - } + final userData = data['data'] as JsonList; + return UserTwitch.fromJson(userData.first); } /// Returns a token for an anonymous user. Future getDefaultToken() async { - final url = Uri( - scheme: 'https', - host: 'id.twitch.tv', - path: '/oauth2/token', + // Use custom base URL for OAuth + final data = await post( + '$_oauthBaseUrl/token', queryParameters: { 'client_id': clientId, 'client_secret': secret, @@ -178,314 +142,612 @@ class TwitchApi { }, ); - final response = await _client.post(url); - if (response.statusCode == 200) { - return jsonDecode(response.body)['access_token']; - } else { - return Future.error('Failed to get default token'); + 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 validateToken({required String token}) async { + try { + final data = await get( + '$_oauthBaseUrl/validate', + headers: {'Authorization': 'Bearer $token'}, + ); + final scopes = data['scopes']; + final scopeList = scopes is List + ? scopes.map((scope) => scope.toString()).toList() + : const []; + final userId = data['user_id']?.toString(); + return TwitchTokenInfo( + scopes: scopeList, + userId: userId == null || userId.isEmpty ? null : userId, + ); + } on UnauthorizedException { + // 401 -> token is invalid/expired (propagated from interceptor for validate requests) + return null; + } on ApiException catch (e) { + // Network/timeout/server errors -> treat as indeterminate, not invalid + debugPrint('Token validation indeterminate: $e'); + rethrow; } } - /// Returns a bool indicating the validity of the given token. - Future validateToken({required String token}) async { - final url = Uri.parse('https://id.twitch.tv/oauth2/validate'); - - final response = - await _client.get(url, headers: {'Authorization': 'Bearer $token'}); - if (response.statusCode == 200) { - return true; - } else { - return false; - } - } - - /// Returns a [StreamsTwitch] object that contains the top 20 streams and a cursor for further requests. + /// 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 getTopStreams({ - required Map headers, String? cursor, + String? language, + int first = 100, }) async { - final url = Uri.parse(cursor == null - ? 'https://api.twitch.tv/helix/streams' - : 'https://api.twitch.tv/helix/streams?after=$cursor'); + final queryParams = { + 'first': first, + if (cursor != null) 'after': cursor, + if (language != null && language.isNotEmpty) 'language': language, + }; - final response = await _client.get(url, headers: headers); - final decoded = jsonDecode(response.body); - if (response.statusCode == 200) { - return StreamsTwitch.fromJson(decoded); - } else { - return Future.error('Failed to get top streams: ${decoded['message']}'); - } + final data = await get( + '/streams', + queryParameters: queryParams, + ); + + return StreamsTwitch.fromJson(data); } /// Returns a [StreamsTwitch] object that contains the given user ID's top 20 followed streams and a cursor for further requests. Future getFollowedStreams({ required String id, - required Map headers, String? cursor, }) async { - final url = Uri.parse(cursor == null - ? 'https://api.twitch.tv/helix/streams/followed?user_id=$id' - : 'https://api.twitch.tv/helix/streams/followed?user_id=$id&after=$cursor'); + final queryParams = {'user_id': id, 'first': '100'}; + if (cursor != null) queryParams['after'] = cursor; - final response = await _client.get(url, headers: headers); - final decoded = jsonDecode(response.body); - if (response.statusCode == 200) { - return StreamsTwitch.fromJson(decoded); - } else { - return Future.error( - 'Failed to get followed streams: ${decoded['message']}'); - } + final data = await get( + '/streams/followed', + queryParameters: queryParams, + ); + + return StreamsTwitch.fromJson(data); + } + + /// Returns a [FollowedChannels] object containing all followed channels (including offline ones) for the given user ID. + Future getFollowedChannels({ + required String userId, + String? cursor, + }) async { + final queryParams = {'user_id': userId, 'first': '100'}; + if (cursor != null) queryParams['after'] = cursor; + + final data = await get( + '/channels/followed', + queryParameters: queryParams, + ); + + return FollowedChannels.fromJson(data); + } + + /// Whether [userId] follows [broadcasterId], via Helix (read-only; create/delete + /// follows were removed from the public API). + Future isFollowingChannel({ + required String userId, + required String broadcasterId, + }) async { + final data = await get( + '/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 getStreamsUnderCategory({ required String gameId, - required Map headers, String? cursor, + String? language, + int first = 100, }) async { - final url = Uri.parse(cursor == null - ? 'https://api.twitch.tv/helix/streams?game_id=$gameId' - : 'https://api.twitch.tv/helix/streams?game_id=$gameId&after=$cursor'); + final queryParams = { + 'game_id': gameId, + 'first': first, + if (cursor != null) 'after': cursor, + if (language != null && language.isNotEmpty) 'language': language, + }; - final response = await _client.get(url, headers: headers); - final decoded = jsonDecode(response.body); - if (response.statusCode == 200) { - return StreamsTwitch.fromJson(decoded); + final data = await get('/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 getDirectoryStreams({ + List 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 = []; + 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 getStreamsMatchingLanguages({ + required List 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 getStream({required String userLogin}) async { + final data = await get( + '/streams', + queryParameters: {'user_login': userLogin}, + ); + + final streamData = data['data'] as JsonList; + if (streamData.isNotEmpty) { + return StreamTwitch.fromJson(streamData.first); } else { - return Future.error( - 'Failed to get streams under category: ${decoded['message']}'); + throw ApiException('$userLogin is offline', 404); } } - /// Returns a [StreamTwitch] object containing the stream info associated with the given [userLogin]. - Future getStream({ - required String userLogin, - required Map headers, - }) async { - final uri = - Uri.parse('https://api.twitch.tv/helix/streams?user_login=$userLogin'); + Future getStreamsByIds({required List userIds}) async { + // Create query string manually for multiple user_id parameters + final userIdParams = userIds.map((id) => 'user_id=$id').join('&'); + final url = '/streams?$userIdParams&first=100'; - final response = await _client.get(uri, headers: headers); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body); - final data = decoded['data'] as List; + final data = await get(url); - if (data.isNotEmpty) { - return StreamTwitch.fromJson(data.first); - } else { - return Future.error('$userLogin is offline'); - } - } else { - return Future.error('Failed to get stream info'); - } + return StreamsTwitch.fromJson(data); } /// Returns a [UserTwitch] object containing the user info associated with the given [userLogin]. - Future getUser({ - String? userLogin, - String? id, - required Map headers, - }) async { - final url = Uri.parse(id != null - ? 'https://api.twitch.tv/helix/users?id=$id' - : 'https://api.twitch.tv/helix/users?login=$userLogin'); + Future getUser({String? userLogin, String? id}) async { + final queryParams = {}; + if (id != null) { + queryParams['id'] = id; + } else if (userLogin != null) { + queryParams['login'] = userLogin; + } - final response = await _client.get(url, headers: headers); - final decoded = jsonDecode(response.body); + final data = await get('/users', queryParameters: queryParams); - if (response.statusCode == 200) { - final userData = decoded['data'] as List; - - if (userData.isNotEmpty) { - return UserTwitch.fromJson(userData.first); - } else { - return Future.error('User does not exist'); - } + final userData = data['data'] as JsonList; + if (userData.isNotEmpty) { + return UserTwitch.fromJson(userData.first); } else { - return Future.error('Failed to get user: ${decoded['message']}'); + throw NotFoundException('User does not exist'); } } /// Returns a [Channel] object containing a channels's info associated with the given [userId]. - Future getChannel({ - required String userId, - required Map headers, - }) async { - final url = Uri.parse( - 'https://api.twitch.tv/helix/channels?broadcaster_id=$userId'); + Future getChannel({required String userId}) async { + final data = await get( + '/channels', + queryParameters: {'broadcaster_id': userId}, + ); - final response = await _client.get(url, headers: headers); - final decoded = jsonDecode(response.body); - if (response.statusCode == 200) { - final channelData = decoded['data'] as List; - - if (channelData.isNotEmpty) { - return Channel.fromJson(channelData.first); - } else { - return Future.error('Channel does not exist'); - } + final channelData = data['data'] as JsonList; + if (channelData.isNotEmpty) { + return Channel.fromJson(channelData.first); } else { - return Future.error('Failed to get channel: ${decoded['message']}'); + throw ApiException('Channel does not exist', 404); } } /// Returns a list of [ChannelQuery] objects closest matching the given [query]. - Future> searchChannels({ - required String query, - required Map headers, - }) async { - final url = Uri.parse( - 'https://api.twitch.tv/helix/search/channels?first=8&query=$query'); + Future> searchChannels({required String query}) async { + final data = await get( + '/search/channels', + queryParameters: {'first': '8', 'query': query}, + ); - final response = await _client.get(url, headers: headers); - if (response.statusCode == 200) { - final channelData = jsonDecode(response.body)['data'] as List; - - return channelData.map((e) => ChannelQuery.fromJson(e)).toList(); - } else { - return Future.error('Failed to get channels'); - } + final channelData = data['data'] as JsonList; + return channelData.map((e) => ChannelQuery.fromJson(e)).toList(); } /// Returns a [CategoriesTwitch] object containing the next top 20 categories/games and a cursor for further requests. - Future getTopCategories({ - required Map headers, - String? cursor, - }) async { - final url = Uri.parse(cursor == null - ? 'https://api.twitch.tv/helix/games/top' - : 'https://api.twitch.tv/helix/games/top?after=$cursor'); + Future getTopCategories({String? cursor}) async { + final data = await get( + '/games/top', + queryParameters: cursor != null ? {'after': cursor} : null, + ); - final response = await _client.get(url, headers: headers); - final decoded = jsonDecode(response.body); - if (response.statusCode == 200) { - return CategoriesTwitch.fromJson(decoded); - } else { - return Future.error( - 'Failed to get top categories: ${decoded['message']}'); - } + return CategoriesTwitch.fromJson(data); } /// Returns a [CategoriesTwitch] object containing the category info corresponding to the provided [gameId]. - Future getCategory({ - required Map headers, - required String gameId, - }) async { - final url = Uri.parse('https://api.twitch.tv/helix/games?id=$gameId'); + Future getCategory({required String gameId}) async { + final data = await get('/games', queryParameters: {'id': gameId}); - final response = await _client.get(url, headers: headers); - final decoded = jsonDecode(response.body); - if (response.statusCode == 200) { - return CategoriesTwitch.fromJson(decoded); - } else { - return Future.error('Failed to get category: ${decoded['message']}'); - } + return CategoriesTwitch.fromJson(data); } /// Returns a [CategoriesTwitch] containing up to 20 categories/games closest matching the [query] and a cursor for further requests. Future searchCategories({ - required Map headers, required String query, String? cursor, }) async { - final url = Uri.parse(cursor == null - ? 'https://api.twitch.tv/helix/search/categories?first=8&query=$query' - : 'https://api.twitch.tv/helix/search/categories?first=8&query=$query&after=$cursor'); + final queryParams = {'first': '8', 'query': query}; + if (cursor != null) queryParams['after'] = cursor; - final response = await _client.get(url, headers: headers); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body); + final data = await get( + '/search/categories', + queryParameters: queryParams, + ); - return CategoriesTwitch.fromJson(decoded); - } else { - return Future.error('Failed to get categories'); - } + return CategoriesTwitch.fromJson(data); } /// Returns the sub count associated with the given [userId]. - Future getSubscriberCount({ - required String userId, - required Map headers, - }) async { - final uri = Uri.parse( - 'https://api.twitch.tv/helix/subscriptions?broadcaster_id=$userId'); + Future getSubscriberCount({required String userId}) async { + final data = await get( + '/subscriptions', + queryParameters: {'broadcaster_id': userId}, + ); - final response = await _client.get(uri, headers: headers); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body); - - return decoded['total'] as int; - } else { - return Future.error('Failed to get sub count'); - } + return data['total'] as int; } /// Returns a user's list of blocked users given their id. Future> getUserBlockedList({ required String id, - required Map headers, String? cursor, }) async { - final url = Uri.parse(cursor == null - ? 'https://api.twitch.tv/helix/users/blocks?first=100&broadcaster_id=$id' - : 'https://api.twitch.tv/helix/users/blocks?first=100&broadcaster_id=$id&after=$cursor'); + final queryParams = {'first': '100', 'broadcaster_id': id}; + if (cursor != null) queryParams['after'] = cursor; - final response = await _client.get(url, headers: headers); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body); - final cursor = decoded['pagination']['cursor']; - final blockedList = decoded['data'] as List; + final data = await get( + '/users/blocks', + queryParameters: queryParams, + ); - if (blockedList.isNotEmpty) { - final result = - blockedList.map((e) => UserBlockedTwitch.fromJson(e)).toList(); + final paginationCursor = data['pagination']['cursor']; + final blockedList = data['data'] as JsonList; - if (cursor != null) { - // Wait a bit (150 milliseconds) before recursively calling. - // This will prevent going over the rate limit to due a massive blocked users list. - // - // With the Twitch API, we can make up to 800 requests per minute. - // Waiting 150 milliseconds between requests will cap the rate here at 400 requests per minute. - await Future.delayed(const Duration(milliseconds: 150)); - result.addAll(await getUserBlockedList( - id: id, cursor: cursor, headers: headers)); - } + if (blockedList.isNotEmpty) { + final result = blockedList + .map((e) => UserBlockedTwitch.fromJson(e)) + .toList(); - return result; - } else { - debugPrint('User does not have anyone blocked'); - return []; + if (paginationCursor != null) { + // Wait a bit (150 milliseconds) before recursively calling. + // This will prevent going over the rate limit to due a massive blocked users list. + // + // With the Twitch API, we can make up to 800 requests per minute. + // Waiting 150 milliseconds between requests will cap the rate here at 400 requests per minute. + await Future.delayed(const Duration(milliseconds: 150)); + result.addAll( + await getUserBlockedList(id: id, cursor: paginationCursor), + ); } + + return result; } else { - return Future.error('User does not exist'); + debugPrint('User does not have anyone blocked'); + return []; } } // Blocks the user with the given ID and returns true on success or false on failure. - Future blockUser( - {required String userId, required Map headers}) async { - final url = Uri.parse( - 'https://api.twitch.tv/helix/users/blocks?target_user_id=$userId'); - - final response = await _client.put(url, headers: headers); - if (response.statusCode == 204) { - return true; - } else { + Future blockUser({required String userId}) async { + try { + await put( + '/users/blocks', + queryParameters: {'target_user_id': userId}, + ); + return true; // If no exception, operation succeeded + } on ApiException { return false; } } // Unblocks the user with the given ID and returns true on success or false on failure. - Future unblockUser( - {required String userId, required Map headers}) async { - final url = Uri.parse( - 'https://api.twitch.tv/helix/users/blocks?target_user_id=$userId'); - - final response = await _client.delete(url, headers: headers); - if (response.statusCode == 204) { - return true; - } else { + Future unblockUser({required String userId}) async { + try { + await delete( + '/users/blocks', + queryParameters: {'target_user_id': userId}, + ); + return true; // If no exception, operation succeeded + } on ApiException { return false; } } + + Future getSharedChatSession({ + required String broadcasterId, + }) async { + final data = await get( + '/shared_chat/session', + queryParameters: {'broadcaster_id': broadcasterId}, + ); + + final sessionData = data['data'] as JsonList; + if (sessionData.isEmpty) { + return null; + } + + return SharedChatSession.fromJson(sessionData.first); + } + + /// Gets the color used for the user's name in chat. + /// [userId] - The ID of the user whose chat color to get + /// Returns the color as a hex string or empty string if no color is set. + Future getUserChatColor({required String userId}) async { + try { + final data = await get( + '/chat/color', + queryParameters: {'user_id': userId}, + ); + + final users = data['data'] as JsonList; + if (users.isNotEmpty) { + final user = users.first as JsonMap; + return user['color'] as String? ?? ''; + } + + return ''; + } on ApiException { + return ''; + } + } + + /// Updates the color used for the user's name in chat. + /// [userId] - The ID of the user whose chat color to update + /// [color] - The color to use. Can be a named color (blue, blue_violet, etc.) or hex code for Turbo/Prime users + /// Returns true on success or false on failure. + Future updateUserChatColor({ + required String userId, + required String color, + }) async { + try { + await put( + '/chat/color', + queryParameters: {'user_id': userId, 'color': color}, + ); + return true; // If no exception, operation succeeded + } on ApiException catch (e) { + // Log the specific error for debugging + debugPrint('Failed to update chat color: $e'); + return false; + } + } + + // Gets recent messages from a third-party service. + Future getRecentMessages({required String userLogin}) async { + // Use custom base URL for third-party service + final data = await get( + '$_recentMessagesUrl/recent-messages/$userLogin', + ); + + 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> 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( + '/moderation/channels', + queryParameters: queryParams, + ); + + final channels = data['data'] as JsonList; + final result = channels + .map((channel) => (channel as JsonMap)['broadcaster_id'] as String?) + .whereType() + .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 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( + '/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 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( + '/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 unbanUser({ + required String broadcasterId, + required String moderatorId, + required String userId, + }) async { + await delete( + '/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 scopes; + + /// Present for user access tokens; omitted for app access tokens. + final String? userId; + + const TwitchTokenInfo({required this.scopes, this.userId}); } diff --git a/lib/apis/twitch_auth_interceptor.dart b/lib/apis/twitch_auth_interceptor.dart new file mode 100644 index 00000000..b5cd0a97 --- /dev/null +++ b/lib/apis/twitch_auth_interceptor.dart @@ -0,0 +1,31 @@ +import 'package:dio/dio.dart'; +import 'package:frosty/screens/settings/stores/auth_store.dart'; + +/// Dio interceptor that automatically adds Twitch authentication headers +/// to requests targeting Twitch API endpoints. +class TwitchAuthInterceptor extends Interceptor { + final AuthStore _authStore; + + const TwitchAuthInterceptor(this._authStore); + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + // Check if this is a Twitch API request that needs authentication + if (_shouldAddTwitchHeaders(options.uri)) { + // Add Twitch auth headers automatically + final twitchHeaders = _authStore.headersTwitch; + options.headers.addAll(twitchHeaders); + } + + handler.next(options); + } + + /// Determines if the request URL is for a Twitch API endpoint that requires authentication + bool _shouldAddTwitchHeaders(Uri uri) { + final url = uri.toString(); + + // Add headers for Twitch Helix API and OAuth endpoints + return url.startsWith('https://api.twitch.tv/helix') || + url.startsWith('https://id.twitch.tv/oauth2'); + } +} diff --git a/lib/apis/twitch_gql_api.dart b/lib/apis/twitch_gql_api.dart new file mode 100644 index 00000000..3923d500 --- /dev/null +++ b/lib/apis/twitch_gql_api.dart @@ -0,0 +1,559 @@ +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 = { + 'Client-ID': twitchGqlClientId, + 'Content-Type': 'application/json', + }; + + Future _gql( + Map body, { + Map? extraHeaders, + }) async { + final response = await post( + '/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 _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 = { + '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 getPlaybackAccessToken({ + required String login, + String? authToken, + String playerType = 'site', + String platform = 'web', + }) async { + final headers = { + '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 query(Map body) => + post('/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 fetchPlaylistText(String url) async { + return get(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> 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() + .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 getStreams({ + List? freeformTags, + List? 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 getGameStreams({ + required String gameId, + List? 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 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 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( + '/integrity', + data: {}, + 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 _mutateFollow({ + required String operationName, + required String sha256Hash, + required String authToken, + required JsonMap variables, + required List successPath, + String? integrityToken, + String? deviceId, + }) async { + final headers = { + '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( + '/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.from(response.first as Map); + } + if (response is Map) { + return Map.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 = []; + 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 [], + ); + } +} diff --git a/lib/apis/twitch_integrity.dart b/lib/apis/twitch_integrity.dart new file mode 100644 index 00000000..efef0346 --- /dev/null +++ b/lib/apis/twitch_integrity.dart @@ -0,0 +1,81 @@ +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? 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.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, + }); +} diff --git a/lib/apis/unauthorized_interceptor.dart b/lib/apis/unauthorized_interceptor.dart new file mode 100644 index 00000000..318a04ce --- /dev/null +++ b/lib/apis/unauthorized_interceptor.dart @@ -0,0 +1,143 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:frosty/main.dart'; +import 'package:frosty/screens/onboarding/login_webview.dart'; +import 'package:frosty/screens/settings/stores/auth_store.dart'; +import 'package:frosty/utils/twitch_oauth_scopes.dart'; +import 'package:frosty/widgets/frosty_dialog.dart'; +import 'package:provider/provider.dart'; + +/// Dio interceptor that catches 401 Unauthorized errors and shows a login dialog +class UnauthorizedInterceptor extends Interceptor { + final AuthStore _authStore; + + UnauthorizedInterceptor(this._authStore); + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + // Check if this is a 401 Unauthorized error on a Twitch Helix/OAuth request + if (err.response?.statusCode == 401 && + _isTwitchHelixOrOAuth(err.requestOptions.uri)) { + // For token validation requests, let the error propagate so validateToken can handle it + if (err.requestOptions.uri.path.endsWith('/validate')) { + handler.next(err); + return; + } + + final path = err.requestOptions.uri.path; + + // Background moderated-channels fetches must never nag — especially for + // core-only tokens after an app upgrade. + if (isTwitchModeratedChannelsPath(path)) { + handler.reject(err); + return; + } + + // Moderator actions without scopes → opt-in Enable flow, not a generic + // "missing permissions" re-login for core scopes. + if (isTwitchModeratorActionPath(path) && + !_authStore.hasModeratorScopes) { + _showAuthDialog( + title: 'Enable moderator tools', + message: enableModeratorToolsDialogMessage(), + primaryLabel: 'Enable', + onPrimary: () => openEnableModeratorTools(_authStore), + ); + handler.reject(err); + return; + } + + // Core-only messaging: if core scopes are present, prefer "Session expired" + // over "Missing permissions" (401 ≠ always missing scope). + final isLoggedIn = _authStore.isLoggedIn; + final missingCore = missingTwitchScopes( + granted: _authStore.grantedScopes, + ); + _showAuthDialog( + title: isLoggedIn && missingCore.isNotEmpty + ? 'Missing permissions' + : 'Session expired', + message: unauthorizedDialogMessage( + isLoggedIn: isLoggedIn, + grantedScopes: _authStore.grantedScopes, + ), + primaryLabel: 'Log in', + onPrimary: () { + final context = navigatorKey.currentContext; + if (context == null) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => Provider.value( + value: _authStore, + child: const LoginWebView(), + ), + ), + ); + }, + ); + handler.reject(err); + return; + } + + // For non-401 errors, continue with normal error handling + handler.next(err); + } + + void _showAuthDialog({ + required String title, + required String message, + required String primaryLabel, + required VoidCallback onPrimary, + }) { + final context = navigatorKey.currentContext; + if (context == null) return; + if (_isDialogShowing) return; + _isDialogShowing = true; + + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext dialogContext) { + return FrostyDialog( + title: title, + message: message, + actions: [ + TextButton( + onPressed: () { + _isDialogShowing = false; + Navigator.of(dialogContext).pop(); + }, + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + _isDialogShowing = false; + Navigator.of(dialogContext).pop(); + onPrimary(); + }, + child: Text(primaryLabel), + ), + ], + ); + }, + ).then((_) { + _isDialogShowing = false; + }); + } + + static bool _isDialogShowing = false; + + bool _isTwitchHelixOrOAuth(Uri uri) { + final url = uri.toString(); + return url.startsWith('https://api.twitch.tv/helix') || + url.startsWith('https://id.twitch.tv/oauth2'); + } +} + +/// Helix path for listing channels the user moderates. +bool isTwitchModeratedChannelsPath(String path) => + path.contains('/moderation/channels'); + +/// Helix paths for delete / ban / timeout / unban. +bool isTwitchModeratorActionPath(String path) => + path.contains('/moderation/chat') || path.contains('/moderation/bans'); diff --git a/lib/cache_manager.dart b/lib/cache_manager.dart new file mode 100644 index 00000000..b3d61f48 --- /dev/null +++ b/lib/cache_manager.dart @@ -0,0 +1,44 @@ +import 'dart:io'; + +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:path_provider/path_provider.dart'; + +class CustomCacheManager { + static const key = 'libCachedImageData'; + static final _repo = CacheObjectProvider(databaseName: key); + static final instance = CacheManager( + Config( + key, + stalePeriod: const Duration(days: 30), + maxNrOfCacheObjects: 10000, + repo: _repo, + ), + ); + + static Future removeOrphanedCacheFiles() async { + final tempDir = await getTemporaryDirectory(); + final cacheDir = Directory('${tempDir.path}/$key'); + + if (!await cacheDir.exists()) { + return; + } + + final allFiles = cacheDir.listSync(recursive: true).toList(); + + await _repo.open(); + final cachedObjects = await _repo.getAllObjects(); + final dbFiles = cachedObjects.map((e) => e.relativePath).toSet(); + + final orphanedFiles = allFiles.where((file) { + final relativePath = file.path.split('$key/').last; + return !dbFiles.contains(relativePath); + }).toList(); + + final deletions = orphanedFiles.map((file) => file.delete()).toList(); + + try { + await Future.wait(deletions); + // ignore: empty_catches + } catch (e) {} + } +} diff --git a/lib/constants.dart b/lib/constants.dart index 68c67376..0b977e2a 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -1,6 +1,20 @@ +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'); @@ -19,8 +33,70 @@ const zeroWidthEmotes = [ /// Regex for matching strings that contain lower or upper case English characters. final regexEnglish = RegExp(r'[a-zA-Z]'); +/// Regex for matching strings that contain only numeric characters. +final regexNumbersOnly = RegExp(r'^\d+$'); + +/// Regex for matching URLs and file names in text. +final regexLink = RegExp( + r'(? chatColorValues = { + 'blue': Color(0xFF0000FF), + 'blue_violet': Color(0xFF8A2BE2), + 'cadet_blue': Color(0xFF5F9EA0), + 'chocolate': Color(0xFFD2691E), + 'coral': Color(0xFFFF7F50), + 'dodger_blue': Color(0xFF1E90FF), + 'firebrick': Color(0xFFB22222), + 'golden_rod': Color(0xFFDAA520), + 'green': Color(0xFF008000), + 'hot_pink': Color(0xFFFF69B4), + 'orange_red': Color(0xFFFF4500), + 'red': Color(0xFFFF0000), + 'sea_green': Color(0xFF2E8B57), + 'spring_green': Color(0xFF00FF7F), + 'yellow_green': Color(0xFF9ACD32), +}; diff --git a/lib/firebase_options.dart b/lib/firebase_options.dart index 5349f973..39d94664 100644 --- a/lib/firebase_options.dart +++ b/lib/firebase_options.dart @@ -63,7 +63,8 @@ class DefaultFirebaseOptions { messagingSenderId: '403282332389', projectId: 'frosty-128de', storageBucket: 'frosty-128de.appspot.com', - iosClientId: '403282332389-gst8k8gl71a7vrhf5k69nl8pnsq19rg8.apps.googleusercontent.com', + iosClientId: + '403282332389-gst8k8gl71a7vrhf5k69nl8pnsq19rg8.apps.googleusercontent.com', iosBundleId: 'com.tommychow.frosty', ); } diff --git a/lib/main.dart b/lib/main.dart index 437f2259..b0b87d00 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,43 +1,129 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:device_preview/device_preview.dart'; +import 'package:advanced_in_app_review/advanced_in_app_review.dart'; +import 'package:app_links/app_links.dart'; +import 'package:dio/dio.dart'; +import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:firebase_performance/firebase_performance.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:frosty/apis/base_api_client.dart' show ApiException; import 'package:frosty/apis/bttv_api.dart'; +import 'package:frosty/apis/dio_client.dart'; import 'package:frosty/apis/ffz_api.dart'; import 'package:frosty/apis/seventv_api.dart'; import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/apis/twitch_auth_interceptor.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; +import 'package:frosty/apis/unauthorized_interceptor.dart'; +import 'package:frosty/cache_manager.dart'; +import 'package:frosty/constants.dart'; import 'package:frosty/firebase_options.dart'; +import 'package:frosty/screens/channel/channel.dart'; import 'package:frosty/screens/home/home.dart'; import 'package:frosty/screens/onboarding/onboarding_intro.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; -import 'package:http/http.dart'; +import 'package:frosty/stores/global_assets_store.dart'; +import 'package:frosty/theme.dart'; +import 'package:frosty/utils.dart'; +import 'package:frosty/widgets/alert_message.dart'; import 'package:mobx/mobx.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +/// Errors that are expected on a mobile client — transient network/I/O, plugin +/// teardown races, and image fetch failures — and therefore shouldn't be +/// reported to Crashlytics as fatal crashes (which would tank the crash-free +/// metric and bucket unrelated errors together under the global handlers). +/// +/// This is a last-resort net. The layers that own these errors already record +/// them non-fatally with precise reasons (e.g. base_api_client, the IRC +/// websocket handlers); this only catches what escapes that local handling. +bool _isNonFatalError(Object error) { + return error is SocketException || + error is HttpException || + error is WebSocketChannelException || + error is ApiException || + error is MissingPluginException || + error is NetworkImageLoadException || + (error is DioException && error.type != DioExceptionType.badResponse); +} void main() async { WidgetsFlutterBinding.ensureInitialized(); + CustomCacheManager.removeOrphanedCacheFiles(); + + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + + // Route uncaught framework errors to Crashlytics. Non-crashing UI errors + // (layout overflows, image-load failures) and other expected transient + // failures are recorded as non-fatal so they don't inflate the crash count. + FlutterError.onError = (details) { + // Run the cheap type checks first; only fall back to the diagnostics string + // (RenderFlex overflow has no typed signal) when those miss. + final isNonFatal = + _isNonFatalError(details.exception) || + details.exceptionAsString().contains('overflowed'); + if (isNonFatal) { + FirebaseCrashlytics.instance.recordFlutterError(details); + } else { + FirebaseCrashlytics.instance.recordFlutterFatalError(details); + } + }; + + // Pass uncaught asynchronous errors to Crashlytics. Expected transient errors + // are recorded non-fatally; everything else stays fatal. The reason gives the + // grouping engine a real signal so distinct errors don't collapse into one + // catch-all issue under this handler frame. + PlatformDispatcher.instance.onError = (error, stack) { + FirebaseCrashlytics.instance.recordError( + error, + stack, + reason: error.runtimeType.toString(), + fatal: !_isNonFatalError(error), + ); + 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(); - await storage.deleteAll(); + // 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 initUtils(); + // With the shared preferences instance, obtain the existing user settings if it exists. // If default settings don't exist, use an empty JSON string to use the default values. final userSettings = prefs.getString('settings') ?? '{}'; @@ -45,50 +131,72 @@ 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))); - await Firebase.initializeApp( - options: DefaultFirebaseOptions.currentPlatform, + /// Initialize API services with a common Dio client. + /// This will prevent every request from creating a new client instance. + final dioClient = DioClient.createClient(); + + // Create API services + final twitchApiService = TwitchApi(dioClient); + final bttvApiService = BTTVApi(dioClient); + final ffzApiService = FFZApi(dioClient); + final sevenTVApiService = SevenTVApi(dioClient); + final twitchGqlApiService = TwitchGqlApi(dioClient); + + // Create global assets store (shared cache for global emotes/badges) + final globalAssetsStore = GlobalAssetsStore( + twitchApi: twitchApiService, + bttvApi: bttvApiService, + ffzApi: ffzApiService, + sevenTVApi: sevenTVApiService, ); - // Pass all uncaught "fatal" errors from the framework to Crashlytics - FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError; - - // Pass all uncaught asynchronous errors that aren't handled by the Flutter framework to Crashlytics - PlatformDispatcher.instance.onError = (error, stack) { - FirebaseCrashlytics.instance.recordError(error, stack, fatal: true); - return true; - }; - - /// Initialize API services with a common client. - /// This will prevent every request from creating a new client instance. - final client = Client(); - final twitchApiService = TwitchApi(client); - final bttvApiService = BTTVApi(client); - final ffzApiService = FFZApi(client); - final sevenTVApiService = SevenTVApi(client); - // Create and initialize the authentication store - final authStore = AuthStore(twitchApi: twitchApiService); + final authStore = AuthStore( + twitchApi: twitchApiService, + twitchGqlApi: twitchGqlApiService, + ); + + // Add the auth interceptor to the Dio client after AuthStore creation + dioClient.interceptors.add(TwitchAuthInterceptor(authStore)); + + // Add the unauthorized interceptor to catch 401 errors + dioClient.interceptors.add(UnauthorizedInterceptor(authStore)); + 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( - DevicePreview( - enabled: false, - builder: (context) { - return MultiProvider( - providers: [ - Provider(create: (_) => authStore), - Provider(create: (_) => settingsStore), - Provider(create: (_) => twitchApiService), - Provider(create: (_) => bttvApiService), - Provider(create: (_) => ffzApiService), - Provider(create: (_) => sevenTVApiService), - ], - child: MyApp(firstRun: firstRun), - ); - }, + MultiProvider( + providers: [ + Provider.value(value: authStore), + Provider.value(value: settingsStore), + Provider.value(value: twitchApiService), + Provider.value(value: bttvApiService), + Provider.value(value: ffzApiService), + Provider.value(value: sevenTVApiService), + Provider.value(value: twitchGqlApiService), + Provider.value(value: globalAssetsStore), + ], + child: MyApp(firstRun: firstRun), ), ); } @@ -96,156 +204,146 @@ void main() async { // Navigator key for sleep timer. Allows navigation popping without context. final navigatorKey = GlobalKey(); -const gray = Color.fromRGBO(18, 18, 18, 1.0); -const lightGray = Color.fromRGBO(28, 28, 28, 1.0); -const purple = Color(0xff9146ff); - -const inputTheme = InputDecorationTheme( - filled: true, - contentPadding: EdgeInsets.all(10.0), - border: OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(10.0)), - borderSide: BorderSide(style: BorderStyle.none), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(10.0)), - borderSide: BorderSide(style: BorderStyle.none), - ), - disabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(10.0)), - borderSide: BorderSide(style: BorderStyle.none), - ), -); - -const tooltipTheme = TooltipThemeData( - padding: EdgeInsets.all(10.0), - margin: EdgeInsets.symmetric(horizontal: 5.0), - decoration: BoxDecoration( - color: lightGray, - borderRadius: BorderRadius.all(Radius.circular(10.0)), - ), - textStyle: TextStyle(color: Colors.white), -); - -const snackBarTheme = SnackBarThemeData( - backgroundColor: lightGray, - contentTextStyle: TextStyle(color: Colors.white), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(10.0)), - ), -); - -final lightTheme = ThemeData( - canvasColor: Colors.white, - splashFactory: Platform.isIOS ? NoSplash.splashFactory : null, - fontFamily: 'Inter', - appBarTheme: const AppBarTheme( - color: Colors.white, - elevation: 0.0, - titleTextStyle: TextStyle( - fontFamily: 'Inter', - fontSize: 24, - fontWeight: FontWeight.bold, - color: Colors.black, - ), - iconTheme: IconThemeData(color: Colors.black), - ), - colorScheme: ColorScheme.fromSwatch( - primarySwatch: Colors.deepPurple, - accentColor: purple, - ), - tabBarTheme: const TabBarTheme( - labelColor: Colors.black, - unselectedLabelColor: Colors.grey, - ), - inputDecorationTheme: inputTheme, - tooltipTheme: tooltipTheme, - snackBarTheme: snackBarTheme, -); - -final darkTheme = ThemeData( - canvasColor: gray, - brightness: Brightness.dark, - splashFactory: Platform.isIOS ? NoSplash.splashFactory : null, - fontFamily: 'Inter', - appBarTheme: const AppBarTheme( - color: gray, - elevation: 0.0, - titleTextStyle: TextStyle( - fontFamily: 'Inter', - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - bottomNavigationBarTheme: - const BottomNavigationBarThemeData(backgroundColor: gray), - colorScheme: ColorScheme.fromSwatch( - brightness: Brightness.dark, - primarySwatch: Colors.deepPurple, - accentColor: purple, - ), - dialogBackgroundColor: gray, - inputDecorationTheme: inputTheme, - tooltipTheme: tooltipTheme, - snackBarTheme: snackBarTheme, -); - -final oledTheme = ThemeData( - canvasColor: Colors.black, - splashFactory: Platform.isIOS ? NoSplash.splashFactory : null, - fontFamily: 'Inter', - appBarTheme: const AppBarTheme( - color: Colors.black, - elevation: 0.0, - titleTextStyle: TextStyle( - fontFamily: 'Inter', - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - colorScheme: ColorScheme.fromSwatch( - brightness: Brightness.dark, - primarySwatch: Colors.deepPurple, - accentColor: purple, - ), - dialogBackgroundColor: Colors.black, - inputDecorationTheme: inputTheme, - tooltipTheme: tooltipTheme, - snackBarTheme: snackBarTheme, -); - -class MyApp extends StatelessWidget { +class MyApp extends StatefulWidget { final bool firstRun; - const MyApp({ - Key? key, - this.firstRun = false, - }) : super(key: key); + const MyApp({super.key, this.firstRun = false}); + + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + final AppLinks _appLinks = AppLinks(); + StreamSubscription? _linkSubscription; + + @override + void initState() { + super.initState(); + + AdvancedInAppReview() + .setMinDaysBeforeRemind(7) + .setMinDaysAfterInstall(1) + .setMinLaunchTimes(5) + .setMinSecondsBeforeShowDialog(3) + .monitor(); + + _initDeepLinks(); + } @override Widget build(BuildContext context) { return Observer( builder: (context) { final settingsStore = context.read(); + final themes = FrostyThemes( + colorSchemeSeed: Color(settingsStore.accentColor), + ); return MaterialApp( - useInheritedMediaQuery: true, - locale: DevicePreview.locale(context), title: 'Frosty', - theme: lightTheme, - darkTheme: settingsStore.themeType == ThemeType.dark || - settingsStore.themeType == ThemeType.system - ? darkTheme - : oledTheme, + theme: themes.light, + darkTheme: themes.dark, themeMode: settingsStore.themeType == ThemeType.system ? ThemeMode.system : settingsStore.themeType == ThemeType.light - ? ThemeMode.light - : ThemeMode.dark, - home: firstRun ? const OnboardingIntro() : const Home(), + ? ThemeMode.light + : ThemeMode.dark, + navigatorObservers: [ + FirebaseAnalyticsObserver( + analytics: FirebaseAnalytics.instance, + ), + ], + home: widget.firstRun ? const OnboardingIntro() : const Home(), navigatorKey: navigatorKey, ); }, ); } + + Future _initDeepLinks() async { + try { + // Handle links when app is already open + _linkSubscription = _appLinks.uriLinkStream.listen((uri) { + handleDeepLink(uri); + }); + + // Handle the initial link if app was opened from a link. + // Add timeout to prevent indefinite blocking on certain Android lifecycle states. + final initialLink = await _appLinks.getInitialLink().timeout( + const Duration(seconds: 3), + onTimeout: () => null, + ); + if (initialLink != null) { + handleDeepLink(initialLink); + } + } catch (e) { + debugPrint('Failed to initialize deep links: $e'); + } + } + + Future handleDeepLink(Uri uri) async { + final failureSnackbar = SnackBar( + content: AlertMessage( + message: 'Unable to navigate to \'$uri\'', + centered: false, + trailingIcon: Icons.open_in_browser_rounded, + // Fallback, allow user to open URL outside app + onTrailingIconPressed: () async { + await launchUrl( + uri, + mode: LaunchMode.inAppWebView, // Force browser + ); + }, + ), + ); + + // Handle channel links + if (uri.pathSegments.isNotEmpty) { + final channelName = uri.pathSegments.first; + + try { + final twitchApi = context.read(); + + final user = await twitchApi.getUser(userLogin: channelName); + + final route = MaterialPageRoute( + settings: const RouteSettings(name: VideoChat.routeName), + builder: (context) => VideoChat( + userId: user.id, + userName: user.displayName, + userLogin: user.login, + ), + ); + + if (navigatorKey.currentState == null) return; + + WidgetsBinding.instance.addPostFrameCallback( + (_) => navigatorKey.currentState?.push(route), + ); + } catch (e) { + // If we get here, there was most likely an error with the Twitch API call and/or this isn't really a channel link + debugPrint('Failed to open link $uri due to error: $e'); + + if (navigatorKey.currentContext == null) return; + ScaffoldMessenger.of( + navigatorKey.currentContext!, + ).showSnackBar(failureSnackbar); + } + } + // TODO: Here we can implement handlers for other types of links + else { + // If we get here, it's a link format that we're unable to handle + if (navigatorKey.currentContext == null) return; + ScaffoldMessenger.of( + navigatorKey.currentContext!, + ).showSnackBar(failureSnackbar); + } + } + + @override + void dispose() { + _linkSubscription?.cancel(); + super.dispose(); + } } diff --git a/lib/models/badges.dart b/lib/models/badges.dart index 70faf87c..7936f8b3 100644 --- a/lib/models/badges.dart +++ b/lib/models/badges.dart @@ -35,12 +35,7 @@ class BadgeInfoFFZ { final String color; final BadgeUrlsFFZ urls; - const BadgeInfoFFZ( - this.id, - this.title, - this.color, - this.urls, - ); + const BadgeInfoFFZ(this.id, this.title, this.color, this.urls); factory BadgeInfoFFZ.fromJson(Map json) => _$BadgeInfoFFZFromJson(json); @@ -55,11 +50,7 @@ class BadgeUrlsFFZ { @JsonKey(name: '4') final String url4x; - const BadgeUrlsFFZ( - this.url1x, - this.url2x, - this.url4x, - ); + const BadgeUrlsFFZ(this.url1x, this.url2x, this.url4x); factory BadgeUrlsFFZ.fromJson(Map json) => _$BadgeUrlsFFZFromJson(json); @@ -71,11 +62,7 @@ class BadgeInfo7TV { final List> urls; final List users; - BadgeInfo7TV( - this.tooltip, - this.urls, - this.users, - ); + BadgeInfo7TV(this.tooltip, this.urls, this.users); factory BadgeInfo7TV.fromJson(Map json) => _$BadgeInfo7TVFromJson(json); @@ -86,10 +73,7 @@ class BadgeInfoBTTV { final String providerId; final BadgeDetailsBTTV badge; - BadgeInfoBTTV( - this.providerId, - this.badge, - ); + BadgeInfoBTTV(this.providerId, this.badge); factory BadgeInfoBTTV.fromJson(Map json) => _$BadgeInfoBTTVFromJson(json); @@ -100,10 +84,7 @@ class BadgeDetailsBTTV { final String description; final String svg; - BadgeDetailsBTTV( - this.description, - this.svg, - ); + BadgeDetailsBTTV(this.description, this.svg); factory BadgeDetailsBTTV.fromJson(Map json) => _$BadgeDetailsBTTVFromJson(json); @@ -123,34 +104,48 @@ class ChatBadge { }); factory ChatBadge.fromTwitch(BadgeInfoTwitch badge) => ChatBadge( - name: badge.title, - url: badge.imageUrl4x, - type: BadgeType.twitch, - ); + name: badge.title, + url: badge.imageUrl4x, + type: BadgeType.twitch, + ); factory ChatBadge.fromBTTV(BadgeInfoBTTV badge) => ChatBadge( - name: badge.badge.description, - url: badge.badge.svg, - type: BadgeType.bttv, - ); + name: badge.badge.description, + url: badge.badge.svg, + type: BadgeType.bttv, + ); factory ChatBadge.fromFFZ(BadgeInfoFFZ badge) => ChatBadge( - name: badge.title, - url: badge.urls.url4x, - color: badge.color, - type: BadgeType.ffz, - ); + name: badge.title, + url: badge.urls.url4x, + color: badge.color, + type: BadgeType.ffz, + ); factory ChatBadge.from7TV(BadgeInfo7TV badge) => ChatBadge( - name: badge.tooltip, - url: badge.urls[2][1], - type: BadgeType.sevenTV, - ); + name: badge.tooltip, + url: badge.urls[2][1], + type: BadgeType.sevenTV, + ); } enum BadgeType { twitch, bttv, ffz, - sevenTV, + sevenTV; + + @override + String toString() { + switch (this) { + case BadgeType.twitch: + return 'Twitch badge'; + case BadgeType.bttv: + return 'BetterTTV badge'; + case BadgeType.ffz: + return 'FrankerFaceZ badge'; + case BadgeType.sevenTV: + return '7TV badge'; + } + } } diff --git a/lib/models/badges.g.dart b/lib/models/badges.g.dart index 275db7cf..5d5d1b79 100644 --- a/lib/models/badges.g.dart +++ b/lib/models/badges.g.dart @@ -17,25 +17,22 @@ BadgeInfoTwitch _$BadgeInfoTwitchFromJson(Map json) => ); BadgeInfoFFZ _$BadgeInfoFFZFromJson(Map json) => BadgeInfoFFZ( - json['id'] as int, - json['title'] as String, - json['color'] as String, - BadgeUrlsFFZ.fromJson(json['urls'] as Map), - ); + (json['id'] as num).toInt(), + json['title'] as String, + json['color'] as String, + BadgeUrlsFFZ.fromJson(json['urls'] as Map), +); -BadgeUrlsFFZ _$BadgeUrlsFFZFromJson(Map json) => BadgeUrlsFFZ( - json['1'] as String, - json['2'] as String, - json['4'] as String, - ); +BadgeUrlsFFZ _$BadgeUrlsFFZFromJson(Map json) => + BadgeUrlsFFZ(json['1'] as String, json['2'] as String, json['4'] as String); BadgeInfo7TV _$BadgeInfo7TVFromJson(Map json) => BadgeInfo7TV( - json['tooltip'] as String, - (json['urls'] as List) - .map((e) => (e as List).map((e) => e as String).toList()) - .toList(), - (json['users'] as List).map((e) => e as String).toList(), - ); + json['tooltip'] as String, + (json['urls'] as List) + .map((e) => (e as List).map((e) => e as String).toList()) + .toList(), + (json['users'] as List).map((e) => e as String).toList(), +); BadgeInfoBTTV _$BadgeInfoBTTVFromJson(Map json) => BadgeInfoBTTV( @@ -44,7 +41,4 @@ BadgeInfoBTTV _$BadgeInfoBTTVFromJson(Map json) => ); BadgeDetailsBTTV _$BadgeDetailsBTTVFromJson(Map json) => - BadgeDetailsBTTV( - json['description'] as String, - json['svg'] as String, - ); + BadgeDetailsBTTV(json['description'] as String, json['svg'] as String); diff --git a/lib/models/category.dart b/lib/models/category.dart index 05dc473a..4132d2c0 100644 --- a/lib/models/category.dart +++ b/lib/models/category.dart @@ -8,11 +8,7 @@ class CategoryTwitch { final String id; final String name; - const CategoryTwitch( - this.boxArtUrl, - this.id, - this.name, - ); + const CategoryTwitch(this.boxArtUrl, this.id, this.name); factory CategoryTwitch.fromJson(Map json) => _$CategoryTwitchFromJson(json); @@ -23,10 +19,7 @@ class CategoriesTwitch { final List data; final Map? pagination; - const CategoriesTwitch( - this.data, - this.pagination, - ); + const CategoriesTwitch(this.data, this.pagination); factory CategoriesTwitch.fromJson(Map json) => _$CategoriesTwitchFromJson(json); diff --git a/lib/models/channel.dart b/lib/models/channel.dart index be13fa41..fae8512b 100644 --- a/lib/models/channel.dart +++ b/lib/models/channel.dart @@ -8,11 +8,19 @@ class Channel { final String broadcasterId; final String broadcasterLogin; final String broadcasterName; + final String broadcasterLanguage; + final String title; + final String gameId; + final String gameName; const Channel({ required this.broadcasterId, required this.broadcasterLogin, required this.broadcasterName, + required this.broadcasterLanguage, + required this.title, + required this.gameId, + required this.gameName, }); factory Channel.fromJson(Map json) => diff --git a/lib/models/channel.g.dart b/lib/models/channel.g.dart index fa31252f..bbb41d04 100644 --- a/lib/models/channel.g.dart +++ b/lib/models/channel.g.dart @@ -7,15 +7,19 @@ part of 'channel.dart'; // ************************************************************************** Channel _$ChannelFromJson(Map json) => Channel( - broadcasterId: json['broadcaster_id'] as String, - broadcasterLogin: json['broadcaster_login'] as String, - broadcasterName: json['broadcaster_name'] as String, - ); + broadcasterId: json['broadcaster_id'] as String, + broadcasterLogin: json['broadcaster_login'] as String, + broadcasterName: json['broadcaster_name'] as String, + broadcasterLanguage: json['broadcaster_language'] as String, + title: json['title'] as String, + gameId: json['game_id'] as String, + gameName: json['game_name'] as String, +); ChannelQuery _$ChannelQueryFromJson(Map json) => ChannelQuery( - broadcasterLogin: json['broadcaster_login'] as String, - displayName: json['display_name'] as String, - id: json['id'] as String, - isLive: json['is_live'] as bool, - startedAt: json['started_at'] as String, - ); + broadcasterLogin: json['broadcaster_login'] as String, + displayName: json['display_name'] as String, + id: json['id'] as String, + isLive: json['is_live'] as bool, + startedAt: json['started_at'] as String, +); diff --git a/lib/models/chatters.dart b/lib/models/chatters.dart index c059807e..8129e30e 100644 --- a/lib/models/chatters.dart +++ b/lib/models/chatters.dart @@ -7,10 +7,7 @@ class ChatUsers { final int chatterCount; final Chatters chatters; - ChatUsers( - this.chatterCount, - this.chatters, - ); + ChatUsers(this.chatterCount, this.chatters); factory ChatUsers.fromJson(Map json) => _$ChatUsersFromJson(json); diff --git a/lib/models/chatters.g.dart b/lib/models/chatters.g.dart index ef6f7562..13349ccd 100644 --- a/lib/models/chatters.g.dart +++ b/lib/models/chatters.g.dart @@ -7,16 +7,16 @@ part of 'chatters.dart'; // ************************************************************************** ChatUsers _$ChatUsersFromJson(Map json) => ChatUsers( - json['chatter_count'] as int, - Chatters.fromJson(json['chatters'] as Map), - ); + (json['chatter_count'] as num).toInt(), + Chatters.fromJson(json['chatters'] as Map), +); Chatters _$ChattersFromJson(Map json) => Chatters( - (json['broadcaster'] as List).map((e) => e as String).toList(), - (json['vips'] as List).map((e) => e as String).toList(), - (json['moderators'] as List).map((e) => e as String).toList(), - (json['staff'] as List).map((e) => e as String).toList(), - (json['admins'] as List).map((e) => e as String).toList(), - (json['global_mods'] as List).map((e) => e as String).toList(), - (json['viewers'] as List).map((e) => e as String).toList(), - ); + (json['broadcaster'] as List).map((e) => e as String).toList(), + (json['vips'] as List).map((e) => e as String).toList(), + (json['moderators'] as List).map((e) => e as String).toList(), + (json['staff'] as List).map((e) => e as String).toList(), + (json['admins'] as List).map((e) => e as String).toList(), + (json['global_mods'] as List).map((e) => e as String).toList(), + (json['viewers'] as List).map((e) => e as String).toList(), +); diff --git a/lib/models/emotes.dart b/lib/models/emotes.dart index 07118cd6..b4b07599 100644 --- a/lib/models/emotes.dart +++ b/lib/models/emotes.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:frosty/constants.dart'; import 'package:json_annotation/json_annotation.dart'; @@ -11,12 +12,7 @@ class EmoteTwitch { final String? emoteType; final String? ownerId; - const EmoteTwitch( - this.id, - this.name, - this.emoteType, - this.ownerId, - ); + const EmoteTwitch(this.id, this.name, this.emoteType, this.ownerId); factory EmoteTwitch.fromJson(Map json) => _$EmoteTwitchFromJson(json); @@ -28,10 +24,7 @@ class EmoteBTTV { final String id; final String code; - const EmoteBTTV( - this.id, - this.code, - ); + const EmoteBTTV(this.id, this.code); factory EmoteBTTV.fromJson(Map json) => _$EmoteBTTVFromJson(json); @@ -42,10 +35,7 @@ class EmoteBTTVChannel { final List channelEmotes; final List sharedEmotes; - const EmoteBTTVChannel( - this.channelEmotes, - this.sharedEmotes, - ); + const EmoteBTTVChannel(this.channelEmotes, this.sharedEmotes); factory EmoteBTTVChannel.fromJson(Map json) => _$EmoteBTTVChannelFromJson(json); @@ -58,11 +48,7 @@ class RoomFFZ { final ImagesFFZ? vipBadge; final ImagesFFZ? modUrls; - const RoomFFZ( - this.set, - this.vipBadge, - this.modUrls, - ); + const RoomFFZ(this.set, this.vipBadge, this.modUrls); factory RoomFFZ.fromJson(Map json) => _$RoomFFZFromJson(json); @@ -77,28 +63,39 @@ class ImagesFFZ { @JsonKey(name: '4') final String? url4x; - const ImagesFFZ( - this.url1x, - this.url2x, - this.url4x, - ); + const ImagesFFZ(this.url1x, this.url2x, this.url4x); factory ImagesFFZ.fromJson(Map json) => _$ImagesFFZFromJson(json); } +@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake) +class OwnerFFZ { + final String displayName; + final String name; + + const OwnerFFZ({required this.displayName, required this.name}); + + factory OwnerFFZ.fromJson(Map json) => + _$OwnerFFZFromJson(json); +} + @JsonSerializable(createToJson: false) class EmoteFFZ { final String name; final int height; final int width; + final OwnerFFZ owner; final ImagesFFZ urls; + final ImagesFFZ? animated; const EmoteFFZ( this.name, this.height, this.width, + this.owner, this.urls, + this.animated, ); factory EmoteFFZ.fromJson(Map json) => @@ -107,99 +104,155 @@ class EmoteFFZ { @JsonSerializable(createToJson: false, fieldRename: FieldRename.snake) class Emote7TV { + final String id; final String name; - final List visibilitySimple; - final List width; - final List height; - final List> urls; + final Emote7TVData data; - const Emote7TV( - this.name, - this.visibilitySimple, - this.width, - this.height, - this.urls, - ); + const Emote7TV(this.id, this.name, this.data); factory Emote7TV.fromJson(Map json) => _$Emote7TVFromJson(json); } +@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake) +class Owner7TV { + final String username; + final String displayName; + + const Owner7TV({required this.username, required this.displayName}); + + factory Owner7TV.fromJson(Map json) => + _$Owner7TVFromJson(json); +} + +@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake) +class Emote7TVData { + final String id; + final String name; + final int flags; + final Owner7TV? owner; + final Emote7TVHost host; + + const Emote7TVData(this.id, this.name, this.flags, this.owner, this.host); + + factory Emote7TVData.fromJson(Map json) => + _$Emote7TVDataFromJson(json); +} + +@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake) +class Emote7TVHost { + final String url; + final List files; + + Emote7TVHost(this.url, this.files); + + factory Emote7TVHost.fromJson(Map json) => + _$Emote7TVHostFromJson(json); +} + +@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake) +class Emote7TVFile { + final String name; + final int width; + final int height; + final String format; + + Emote7TVFile(this.name, this.width, this.height, this.format); + + factory Emote7TVFile.fromJson(Map json) => + _$Emote7TVFileFromJson(json); +} + /// The common emote class. @JsonSerializable() class Emote { final String name; + final String? realName; final int? width; final int? height; final bool zeroWidth; final String url; final EmoteType type; + final String? ownerDisplayName; + final String? ownerUsername; final String? ownerId; const Emote({ required this.name, + this.realName, this.width, this.height, required this.zeroWidth, required this.url, required this.type, + this.ownerDisplayName, + this.ownerUsername, this.ownerId, }); factory Emote.fromTwitch(EmoteTwitch emote, EmoteType type) => Emote( - name: emote.name, - zeroWidth: false, - url: - 'https://static-cdn.jtvnw.net/emoticons/v2/${emote.id}/default/dark/3.0', - type: type, - ownerId: emote.ownerId, - ); + name: emote.name, + zeroWidth: false, + url: + 'https://static-cdn.jtvnw.net/emoticons/v2/${emote.id}/default/dark/3.0', + type: type, + ownerId: emote.ownerId, + ); factory Emote.fromBTTV(EmoteBTTV emote, EmoteType type) => Emote( - name: emote.code, - zeroWidth: zeroWidthEmotes.contains(emote.code), - url: 'https://cdn.betterttv.net/emote/${emote.id}/3x', - type: type, - ); + name: emote.code, + zeroWidth: zeroWidthEmotes.contains(emote.code), + url: 'https://cdn.betterttv.net/emote/${emote.id}/3x', + type: type, + ); factory Emote.fromFFZ(EmoteFFZ emote, EmoteType type) => Emote( - name: emote.name, - zeroWidth: false, - width: emote.width, - height: emote.height, - url: emote.urls.url4x ?? emote.urls.url2x ?? emote.urls.url1x, - type: type, - ); + name: emote.name, + zeroWidth: false, + width: emote.width, + height: emote.height, + url: + emote.animated?.url4x ?? + emote.animated?.url2x ?? + emote.animated?.url1x ?? + emote.urls.url4x ?? + emote.urls.url2x ?? + emote.urls.url1x, + type: type, + ownerDisplayName: emote.owner.displayName, + ownerUsername: emote.owner.name, + ); - factory Emote.from7TV(Emote7TV emote, EmoteType type) => Emote( - name: emote.name, - width: emote.width.first, - height: emote.height.first, - zeroWidth: emote.visibilitySimple.contains('ZERO_WIDTH'), - url: emote.urls[3][1], - type: type, - ); + factory Emote.from7TV(Emote7TV emote, EmoteType type) { + final emoteData = emote.data; + + final url = emoteData.host.url; + + // TODO: Remove if/when Flutter natively supports AVIF. + final file = emoteData.host.files.lastWhereOrNull( + (file) => file.format != 'AVIF', + ); + + // Check if the flag has 1 at the 8th bit. + final isZeroWidth = (emoteData.flags & 256) == 256; + + return Emote( + name: emote.name, + realName: emote.name != emoteData.name ? emoteData.name : null, + width: emoteData.host.files.firstOrNull?.width, + height: emoteData.host.files.firstOrNull?.height, + zeroWidth: isZeroWidth, + url: file != null ? 'https:$url/${file.name}' : '', + type: type, + ownerDisplayName: emoteData.owner?.displayName, + ownerUsername: emoteData.owner?.username, + ); + } factory Emote.fromJson(Map json) => _$EmoteFromJson(json); Map toJson() => _$EmoteToJson(this); } -const emoteType = [ - 'Twitch (Bits Tier)', - 'Twitch (Follower)', - 'Twitch (Subscriber)', - 'Twitch (Global)', - 'Twitch (Unlocked)', - 'Twitch (Channel)', - 'FFZ (Global)', - 'FFZ (Channel)', - 'BTTV (Global)', - 'BTTV (Channel)', - 'BTTV (Shared)', - '7TV (Global)', - '7TV (Channel)', -]; - enum EmoteType { twitchBits, twitchFollower, @@ -213,5 +266,37 @@ enum EmoteType { bttvChannel, bttvShared, sevenTVGlobal, - sevenTVChannel, + sevenTVChannel; + + @override + String toString() { + switch (this) { + case EmoteType.twitchBits: + return 'Twitch bits emote'; + case EmoteType.twitchFollower: + return 'Twitch follower emote'; + case EmoteType.twitchSub: + return 'Twitch sub emote'; + case EmoteType.twitchGlobal: + return 'Twitch global emote'; + case EmoteType.twitchUnlocked: + return 'Twitch unlocked emote'; + case EmoteType.twitchChannel: + return 'Twitch channel emote'; + case EmoteType.ffzGlobal: + return 'FrankerFaceZ global emote'; + case EmoteType.ffzChannel: + return 'FrankerFaceZ channel emote'; + case EmoteType.bttvGlobal: + return 'BetterTTV global emote'; + case EmoteType.bttvChannel: + return 'BetterTTV channel emote'; + case EmoteType.bttvShared: + return 'BetterTTV shared emote'; + case EmoteType.sevenTVGlobal: + return '7TV global emote'; + case EmoteType.sevenTVChannel: + return '7TV channel emote'; + } + } } diff --git a/lib/models/emotes.g.dart b/lib/models/emotes.g.dart index 33f7349b..6ffb0c2a 100644 --- a/lib/models/emotes.g.dart +++ b/lib/models/emotes.g.dart @@ -7,16 +7,14 @@ part of 'emotes.dart'; // ************************************************************************** EmoteTwitch _$EmoteTwitchFromJson(Map json) => EmoteTwitch( - json['id'] as String, - json['name'] as String, - json['emote_type'] as String?, - json['owner_id'] as String?, - ); + json['id'] as String, + json['name'] as String, + json['emote_type'] as String?, + json['owner_id'] as String?, +); -EmoteBTTV _$EmoteBTTVFromJson(Map json) => EmoteBTTV( - json['id'] as String, - json['code'] as String, - ); +EmoteBTTV _$EmoteBTTVFromJson(Map json) => + EmoteBTTV(json['id'] as String, json['code'] as String); EmoteBTTVChannel _$EmoteBTTVChannelFromJson(Map json) => EmoteBTTVChannel( @@ -29,59 +27,94 @@ EmoteBTTVChannel _$EmoteBTTVChannelFromJson(Map json) => ); RoomFFZ _$RoomFFZFromJson(Map json) => RoomFFZ( - json['set'] as int, - json['vip_badge'] == null - ? null - : ImagesFFZ.fromJson(json['vip_badge'] as Map), - json['mod_urls'] == null - ? null - : ImagesFFZ.fromJson(json['mod_urls'] as Map), - ); + (json['set'] as num).toInt(), + json['vip_badge'] == null + ? null + : ImagesFFZ.fromJson(json['vip_badge'] as Map), + json['mod_urls'] == null + ? null + : ImagesFFZ.fromJson(json['mod_urls'] as Map), +); -ImagesFFZ _$ImagesFFZFromJson(Map json) => ImagesFFZ( - json['1'] as String, - json['2'] as String?, - json['4'] as String?, - ); +ImagesFFZ _$ImagesFFZFromJson(Map json) => + ImagesFFZ(json['1'] as String, json['2'] as String?, json['4'] as String?); + +OwnerFFZ _$OwnerFFZFromJson(Map json) => OwnerFFZ( + displayName: json['display_name'] as String, + name: json['name'] as String, +); EmoteFFZ _$EmoteFFZFromJson(Map json) => EmoteFFZ( - json['name'] as String, - json['height'] as int, - json['width'] as int, - ImagesFFZ.fromJson(json['urls'] as Map), - ); + json['name'] as String, + (json['height'] as num).toInt(), + (json['width'] as num).toInt(), + OwnerFFZ.fromJson(json['owner'] as Map), + ImagesFFZ.fromJson(json['urls'] as Map), + json['animated'] == null + ? null + : ImagesFFZ.fromJson(json['animated'] as Map), +); Emote7TV _$Emote7TVFromJson(Map json) => Emote7TV( - json['name'] as String, - (json['visibility_simple'] as List) - .map((e) => e as String) - .toList(), - (json['width'] as List).map((e) => e as int).toList(), - (json['height'] as List).map((e) => e as int).toList(), - (json['urls'] as List) - .map((e) => (e as List).map((e) => e as String).toList()) - .toList(), - ); + json['id'] as String, + json['name'] as String, + Emote7TVData.fromJson(json['data'] as Map), +); + +Owner7TV _$Owner7TVFromJson(Map json) => Owner7TV( + username: json['username'] as String, + displayName: json['display_name'] as String, +); + +Emote7TVData _$Emote7TVDataFromJson(Map json) => Emote7TVData( + json['id'] as String, + json['name'] as String, + (json['flags'] as num).toInt(), + json['owner'] == null + ? null + : Owner7TV.fromJson(json['owner'] as Map), + Emote7TVHost.fromJson(json['host'] as Map), +); + +Emote7TVHost _$Emote7TVHostFromJson(Map json) => Emote7TVHost( + json['url'] as String, + (json['files'] as List) + .map((e) => Emote7TVFile.fromJson(e as Map)) + .toList(), +); + +Emote7TVFile _$Emote7TVFileFromJson(Map json) => Emote7TVFile( + json['name'] as String, + (json['width'] as num).toInt(), + (json['height'] as num).toInt(), + json['format'] as String, +); Emote _$EmoteFromJson(Map json) => Emote( - name: json['name'] as String, - width: json['width'] as int?, - height: json['height'] as int?, - zeroWidth: json['zeroWidth'] as bool, - url: json['url'] as String, - type: $enumDecode(_$EmoteTypeEnumMap, json['type']), - ownerId: json['ownerId'] as String?, - ); + name: json['name'] as String, + realName: json['realName'] as String?, + width: (json['width'] as num?)?.toInt(), + height: (json['height'] as num?)?.toInt(), + zeroWidth: json['zeroWidth'] as bool, + url: json['url'] as String, + type: $enumDecode(_$EmoteTypeEnumMap, json['type']), + ownerDisplayName: json['ownerDisplayName'] as String?, + ownerUsername: json['ownerUsername'] as String?, + ownerId: json['ownerId'] as String?, +); Map _$EmoteToJson(Emote instance) => { - 'name': instance.name, - 'width': instance.width, - 'height': instance.height, - 'zeroWidth': instance.zeroWidth, - 'url': instance.url, - 'type': _$EmoteTypeEnumMap[instance.type]!, - 'ownerId': instance.ownerId, - }; + 'name': instance.name, + 'realName': instance.realName, + 'width': instance.width, + 'height': instance.height, + 'zeroWidth': instance.zeroWidth, + 'url': instance.url, + 'type': _$EmoteTypeEnumMap[instance.type]!, + 'ownerDisplayName': instance.ownerDisplayName, + 'ownerUsername': instance.ownerUsername, + 'ownerId': instance.ownerId, +}; const _$EmoteTypeEnumMap = { EmoteType.twitchBits: 'twitchBits', diff --git a/lib/models/events.dart b/lib/models/events.dart new file mode 100644 index 00000000..949e3e55 --- /dev/null +++ b/lib/models/events.dart @@ -0,0 +1,61 @@ +import 'package:frosty/models/emotes.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'events.g.dart'; + +@JsonSerializable() +class SevenTVEvent { + final int op; + @JsonKey(includeIfNull: false) + final int? t; + final SevenTVEventData d; + + const SevenTVEvent({required this.op, this.t, required this.d}); + + factory SevenTVEvent.fromJson(Map json) => + _$SevenTVEventFromJson(json); + + Map toJson() => _$SevenTVEventToJson(this); +} + +@JsonSerializable() +class SevenTVEventData { + final String? type; + final Map? condition; + @JsonKey(includeToJson: false) + final SevenTVEventEmoteSetBody? body; + + const SevenTVEventData({required this.type, this.condition, this.body}); + + factory SevenTVEventData.fromJson(Map json) => + _$SevenTVEventDataFromJson(json); + + Map toJson() => _$SevenTVEventDataToJson(this); +} + +@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake) +class SevenTVEventUpdatedEmote { + final Emote7TV? value; + final Emote7TV? oldValue; + + const SevenTVEventUpdatedEmote({this.value, this.oldValue}); + + factory SevenTVEventUpdatedEmote.fromJson(Map json) => + _$SevenTVEventUpdatedEmoteFromJson(json); +} + +@JsonSerializable(createToJson: false) +class SevenTVEventEmoteSetBody { + final Owner7TV actor; + final List? pushed; + final List? pulled; + + const SevenTVEventEmoteSetBody({ + required this.actor, + this.pushed, + this.pulled, + }); + + factory SevenTVEventEmoteSetBody.fromJson(Map json) => + _$SevenTVEventEmoteSetBodyFromJson(json); +} diff --git a/lib/models/events.g.dart b/lib/models/events.g.dart new file mode 100644 index 00000000..33ffff35 --- /dev/null +++ b/lib/models/events.g.dart @@ -0,0 +1,55 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'events.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SevenTVEvent _$SevenTVEventFromJson(Map json) => SevenTVEvent( + op: (json['op'] as num).toInt(), + t: (json['t'] as num?)?.toInt(), + d: SevenTVEventData.fromJson(json['d'] as Map), +); + +Map _$SevenTVEventToJson(SevenTVEvent instance) => + {'op': instance.op, 't': ?instance.t, 'd': instance.d}; + +SevenTVEventData _$SevenTVEventDataFromJson(Map json) => + SevenTVEventData( + type: json['type'] as String?, + condition: (json['condition'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ), + body: json['body'] == null + ? null + : SevenTVEventEmoteSetBody.fromJson( + json['body'] as Map, + ), + ); + +Map _$SevenTVEventDataToJson(SevenTVEventData instance) => + {'type': instance.type, 'condition': instance.condition}; + +SevenTVEventUpdatedEmote _$SevenTVEventUpdatedEmoteFromJson( + Map json, +) => SevenTVEventUpdatedEmote( + value: json['value'] == null + ? null + : Emote7TV.fromJson(json['value'] as Map), + oldValue: json['old_value'] == null + ? null + : Emote7TV.fromJson(json['old_value'] as Map), +); + +SevenTVEventEmoteSetBody _$SevenTVEventEmoteSetBodyFromJson( + Map json, +) => SevenTVEventEmoteSetBody( + actor: Owner7TV.fromJson(json['actor'] as Map), + pushed: (json['pushed'] as List?) + ?.map((e) => SevenTVEventUpdatedEmote.fromJson(e as Map)) + .toList(), + pulled: (json['pulled'] as List?) + ?.map((e) => SevenTVEventUpdatedEmote.fromJson(e as Map)) + .toList(), +); diff --git a/lib/models/followed_channel.dart b/lib/models/followed_channel.dart new file mode 100644 index 00000000..14d299e9 --- /dev/null +++ b/lib/models/followed_channel.dart @@ -0,0 +1,39 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'followed_channel.g.dart'; + +// Object for followed Twitch channels. +@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake) +class FollowedChannel { + final String broadcasterId; + final String broadcasterLogin; + final String broadcasterName; + final DateTime followedAt; + + const FollowedChannel({ + required this.broadcasterId, + required this.broadcasterLogin, + required this.broadcasterName, + required this.followedAt, + }); + + factory FollowedChannel.fromJson(Map json) => + _$FollowedChannelFromJson(json); +} + +// Response object for followed channels API call. +@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake) +class FollowedChannels { + final List data; + final Map pagination; + final int total; + + const FollowedChannels({ + required this.data, + required this.pagination, + required this.total, + }); + + factory FollowedChannels.fromJson(Map json) => + _$FollowedChannelsFromJson(json); +} diff --git a/lib/models/followed_channel.g.dart b/lib/models/followed_channel.g.dart new file mode 100644 index 00000000..d17b7dff --- /dev/null +++ b/lib/models/followed_channel.g.dart @@ -0,0 +1,24 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'followed_channel.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +FollowedChannel _$FollowedChannelFromJson(Map json) => + FollowedChannel( + broadcasterId: json['broadcaster_id'] as String, + broadcasterLogin: json['broadcaster_login'] as String, + broadcasterName: json['broadcaster_name'] as String, + followedAt: DateTime.parse(json['followed_at'] as String), + ); + +FollowedChannels _$FollowedChannelsFromJson(Map json) => + FollowedChannels( + data: (json['data'] as List) + .map((e) => FollowedChannel.fromJson(e as Map)) + .toList(), + pagination: Map.from(json['pagination'] as Map), + total: (json['total'] as num).toInt(), + ); diff --git a/lib/models/irc.dart b/lib/models/irc.dart index b75c102d..ba00cb52 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -1,16 +1,33 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:collection/collection.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:frosty/cache_manager.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/models/badges.dart'; import 'package:frosty/models/emotes.dart'; +import 'package:frosty/models/user.dart'; import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; -import 'package:frosty/widgets/cached_image.dart'; +import 'package:frosty/utils.dart' as utils; +import 'package:frosty/utils/modal_bottom_sheet.dart'; +import 'package:frosty/widgets/frosty_cached_network_image.dart'; +import 'package:frosty/widgets/frosty_photo_view_dialog.dart'; import 'package:intl/intl.dart'; import 'package:url_launcher/url_launcher.dart'; +// Constants for IRC message rendering +const String _ircActionPrefix = '\x01'; +const String _ircActionSuffix = '\x01'; +const int _ircActionPrefixLength = 8; +const String _invalidUnicodeChar = '\u{E0000}'; +const String _twitchEmoteBaseUrl = 'https://static-cdn.jtvnw.net/emoticons/v2'; +const String _twitchIrcServer = 'tmi.twitch.tv'; +const String _emoteModeSuffix = '/default/dark/3.0'; +const double _emojiSizeOffset = 5.0; + /// The object representation of a Twitch IRC message. class IRCMessage { final String raw; @@ -22,6 +39,8 @@ class IRCMessage { List? split; bool? action; bool? mention; + final VoidCallback? actionCallback; + final String? actionLabel; IRCMessage({ required this.raw, @@ -33,8 +52,15 @@ class IRCMessage { this.split, this.action, this.mention, + this.actionCallback, + this.actionLabel, }); + /// The channel this message actually originated from. In Twitch shared + /// chat, `source-room-id` names the participant channel a message was + /// sent in; otherwise it's just this connection's own room. + String? get sourceChannelId => tags['source-room-id'] ?? tags['room-id']; + /// Applies the given CLEARCHAT message to the provided messages and buffer. static void clearChat({ required List messages, @@ -46,7 +72,8 @@ class IRCMessage { messages.clear(); bufferedMessages.clear(); messages.add( - IRCMessage.createNotice(message: 'Chat was cleared by a moderator')); + IRCMessage.createNotice(message: 'Chat was cleared by a moderator'), + ); return; } @@ -105,80 +132,164 @@ class IRCMessage { } } - /// Returns an [InlineSpan] list that corresponds to the badges, username, words, and emotes of the given [IRCMessage]. - List generateSpan({ - required TextStyle? style, - required ChatAssetsStore assetsStore, - required double badgeScale, - required double emoteScale, - required bool isLightTheme, - required bool launchExternal, - required void Function()? onTapName, - bool showMessage = true, - bool useReadableColors = false, - TimestampType timestamp = TimestampType.disabled, - }) { - final emoteToObject = assetsStore.emoteToObject; + /// Adds timestamp to the span if enabled + void _addTimestamp( + List span, + TextStyle? style, + TimestampType timestamp, + ) { + if (timestamp == TimestampType.disabled) return; + + final time = + tags['tmi-sent-ts'] ?? DateTime.now().millisecondsSinceEpoch.toString(); + final parsedTime = DateTime.fromMillisecondsSinceEpoch(int.parse(time)); + + final timeText = timestamp == TimestampType.twentyFour + ? '${DateFormat.Hm().format(parsedTime)} ' + : '${DateFormat('h:mm').format(parsedTime)} '; + + span.add( + TextSpan( + text: timeText, + style: style?.copyWith( + color: style.color?.withValues(alpha: 0.5), + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ); + } + + /// Adds historical and channel source badges to the span + void _addHistoricalAndChannelBadges( + BuildContext context, + List span, + double badgeSize, + Map? channelIdToUserTwitch, + String? currentChannelId, + ) { + final isHistorical = tags['historical'] == '1'; + if (isHistorical) { + span.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Tooltip( + message: 'Historical message', + preferBelow: false, + triggerMode: TooltipTriggerMode.tap, + child: Icon( + Icons.history_rounded, + size: badgeSize, + color: + Theme.of(context).iconTheme.color?.withValues(alpha: 0.5) ?? + Theme.of(context).colorScheme.onSurfaceVariant.withValues( + alpha: 0.5, + ), + ), + ), + ), + ); + span.add(const TextSpan(text: ' ')); + } + + final sourceChannelUser = channelIdToUserTwitch?[sourceChannelId]; + if (sourceChannelUser != null) { + final isCurrentChannel = sourceChannelId == currentChannelId; + + span.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Tooltip( + triggerMode: TooltipTriggerMode.tap, + preferBelow: false, + message: isCurrentChannel + ? 'Sent from ${sourceChannelUser.displayName} (current channel)' + : 'Sent from ${sourceChannelUser.displayName}', + child: CachedNetworkImage( + cacheManager: CustomCacheManager.instance, + imageUrl: sourceChannelUser.profileImageUrl, + imageBuilder: (context, imageProvider) => Container( + width: badgeSize, + height: badgeSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: isCurrentChannel + ? Border.all( + color: Theme.of(context).colorScheme.primary, + width: 1.5, + ) + : null, + ), + child: Container( + margin: isCurrentChannel ? const EdgeInsets.all(1.5) : null, + decoration: BoxDecoration( + shape: BoxShape.circle, + image: DecorationImage( + image: imageProvider, + fit: BoxFit.cover, + ), + ), + ), + ), + placeholder: (context, url) => Container( + width: badgeSize, + height: badgeSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: isCurrentChannel + ? Border.all( + color: Theme.of(context).colorScheme.primary, + width: 1.5, + ) + : null, + ), + ), + ), + ), + ), + ); + span.add(const TextSpan(text: ' ')); + } + } + + /// Adds all user badges (Twitch, FFZ, BTTV, 7TV) to the span + bool _addUserBadges( + BuildContext context, + List span, + ChatAssetsStore assetsStore, + double badgeSize, + bool launchExternal, + bool isHistorical, + ) { + var skipBot = false; final twitchBadgeToObject = assetsStore.twitchBadgesToObject; final ffzUserToBadges = assetsStore.userToFFZBadges; final sevenTVUserToBadges = assetsStore.userTo7TVBadges; final bttvUserToBadge = assetsStore.userToBTTVBadges; final ffzRoomInfo = assetsStore.ffzRoomInfo; - final badgeSize = defaultBadgeSize * badgeScale; - final emoteSize = defaultEmoteSize * emoteScale; - - // The span list that will be used to render the chat message - final span = []; - - if (timestamp != TimestampType.disabled) { - final time = tags['tmi-sent-ts'] ?? - DateTime.now().millisecondsSinceEpoch.toString(); - final parsedTime = DateTime.fromMillisecondsSinceEpoch(int.parse(time)); - - if (timestamp == TimestampType.twentyFour) { - span.add( - TextSpan( - text: '${DateFormat.Hm().format(parsedTime)} ', - style: style?.copyWith(color: style.color?.withOpacity(0.5)), - ), - ); - } - - if (timestamp == TimestampType.twelve) { - span.add( - TextSpan( - text: '${DateFormat('h:mm').format(parsedTime)} ', - style: style?.copyWith(color: style.color?.withOpacity(0.5)), - ), - ); - } - } - - // Indicator to skip adding the bot badges later when adding the rest of FFZ badges. - var skipBot = false; final ffzUserBadges = ffzUserToBadges[tags['user-id']]; final twitchBadges = tags['badges']?.split(','); - // Pasrse and add the Twitch badges to the span if they exist. + + // Add Twitch badges if (twitchBadges != null) { for (final badge in twitchBadges) { final badgeInfo = twitchBadgeToObject[badge]; if (badgeInfo != null) { var badgeUrl = badgeInfo.url; - // Add custom FFZ mod badge if it exists. + // Handle custom FFZ mod badge if (badgeInfo.name == 'Moderator' && (ffzUserBadges != null || ffzRoomInfo?.modUrls != null)) { - // Check if mod is bot. - final botBadge = ffzUserBadges - ?.firstWhereOrNull((element) => element.name == 'Bot'); + final botBadge = ffzUserBadges?.firstWhereOrNull( + (element) => element.name == 'Bot', + ); - // Check if user has bot badge or room has custom FFZ mod badges if (botBadge != null) { badgeUrl = botBadge.url; skipBot = true; } else if (ffzRoomInfo?.modUrls != null) { - badgeUrl = ffzRoomInfo!.modUrls?.url4x ?? + badgeUrl = + ffzRoomInfo!.modUrls?.url4x ?? ffzRoomInfo.modUrls?.url2x ?? ffzRoomInfo.modUrls!.url1x; } @@ -191,18 +302,21 @@ class IRCMessage { span.add( _createBadgeSpan( + context, badge: newBadge, size: badgeSize, backgroundColor: const Color(0xFF00AD03), + launchExternal: launchExternal, ), ); span.add(const TextSpan(text: ' ')); continue; } - // Add custom FFZ vip badge if it exists + // Handle custom FFZ VIP badge if (badgeInfo.name == 'VIP' && ffzRoomInfo?.vipBadge != null) { - badgeUrl = ffzRoomInfo!.vipBadge?.url4x ?? + badgeUrl = + ffzRoomInfo!.vipBadge?.url4x ?? ffzRoomInfo.vipBadge?.url2x ?? ffzRoomInfo.vipBadge!.url1x; } @@ -215,8 +329,10 @@ class IRCMessage { span.add( _createBadgeSpan( + context, badge: newBadge, size: badgeSize, + launchExternal: launchExternal, ), ); span.add(const TextSpan(text: ' ')); @@ -224,29 +340,34 @@ class IRCMessage { } } - // Add FFZ badges to span + // Add FFZ badges if (ffzUserBadges != null) { for (final badge in ffzUserBadges) { final color = Color(int.parse(badge.color!.replaceFirst('#', '0xFF'))); if (badge.name == 'Bot') { if (!skipBot) { + final indexToInsert = isHistorical ? 2 : 0; span.insert( - 0, + indexToInsert, _createBadgeSpan( + context, badge: badge, size: badgeSize, backgroundColor: color, + launchExternal: launchExternal, ), ); - span.add(const TextSpan(text: ' ')); + span.insert(indexToInsert + 1, const TextSpan(text: ' ')); } } else { span.add( _createBadgeSpan( + context, badge: badge, size: badgeSize, backgroundColor: color, + launchExternal: launchExternal, ), ); span.add(const TextSpan(text: ' ')); @@ -254,238 +375,360 @@ class IRCMessage { } } - // Add BTTV badges to span + // Add BTTV badges final userBTTVBadge = bttvUserToBadge[tags['user-id']]; if (userBTTVBadge != null) { span.add( _createBadgeSpan( + context, badge: userBTTVBadge, size: badgeSize, isSvg: true, + launchExternal: launchExternal, ), ); span.add(const TextSpan(text: ' ')); } - // Add 7TV badges to end of badges span + // Add 7TV badges final user7TVBadges = sevenTVUserToBadges[tags['user-id']]; if (user7TVBadges != null) { for (final badge in user7TVBadges) { span.add( _createBadgeSpan( + context, badge: badge, size: badgeSize, + launchExternal: launchExternal, ), ); span.add(const TextSpan(text: ' ')); } } + return skipBot; + } + + /// Adds the username to the span with proper color and styling + void _addUsername( + List span, + BuildContext context, + void Function()? onTapName, + ) { var color = Color( - int.parse((tags['color'] ?? '#868686').replaceFirst('#', '0xFF'))); + int.parse((tags['color'] ?? '#868686').replaceFirst('#', '0xFF')), + ); - if (useReadableColors) { - final hsl = HSLColor.fromColor(color); - if (isLightTheme == true) { - if (hsl.lightness >= 0.5) { - color = hsl - .withLightness(hsl.lightness + ((0 - hsl.lightness) * 0.5)) - .toColor(); - } - } else { - if (hsl.lightness <= 0.5) { - color = hsl - .withLightness(hsl.lightness + ((1 - hsl.lightness) * 0.5)) - .toColor(); - } - } - } + // Adjust color for theme contrast using the common utility + color = utils.adjustChatNameColor(context, color); - // Add the display name (username) to the span and apply the onLongPressName callback. final displayName = tags['display-name']!; span.add( TextSpan( - text: regexEnglish.hasMatch(displayName) - ? displayName - : '$displayName ($user)', - style: TextStyle( - color: color, - fontWeight: FontWeight.bold, - ), + text: user != null + ? utils.getReadableName(displayName, user!) + : displayName, + style: TextStyle(color: color, fontWeight: FontWeight.bold), recognizer: TapGestureRecognizer()..onTap = onTapName, ), ); - // Add the colon separator between the username and their message to the span. + // Add the colon separator between the username and their message if (action == false) span.add(const TextSpan(text: ':')); + } - // Italicize the text if it was called with an IRC Action (e.g., "/me"). - final textStyle = - action == true ? const TextStyle(fontStyle: FontStyle.italic) : style; - + /// Adds message content with emotes, emojis and text processing + void _addMessageContent( + BuildContext context, + List span, + Map emoteToObject, + double emoteSize, + double emoteScale, + bool showMessage, + bool launchExternal, + TextStyle? textStyle, + void Function(String)? onTapPingedUser, + void Function()? onTapDeletedMessage, { + Color? linkColor, + }) { if (!showMessage) { - span.add(const TextSpan(text: ' ')); - } else { - // Check if the message is a reply. If it is, remove the reply username (first word) from the message. - final words = tags.containsKey('reply-parent-display-name') - ? split?.sublist(1) - : split; - - // Add the message and any emotes to the span. - if (words != null) { - // Keep a local span which will be reversed and added to the final span. - final localSpan = []; - - // Iterate through the words in reverse-order. - // Index starts at the last word in the message. - var index = words.length - 1; - - // Terminate after the first word in the message reached. - while (index != -1) { - // Check if current word is a valid emote. - final word = words[index]; - final emote = emoteToObject[word] ?? localEmotes?[word]; - - if (emote != null) { - // If the emote is zero-width and not the first word, process the stack. - if (emote.zeroWidth && index != 0) { - final emoteStack = []; - - // Handle stacking consecutive zero-width emotes. - Emote? nextEmote = emote; - while (nextEmote != null && nextEmote.zeroWidth && index != 0) { - emoteStack.add(nextEmote); - index--; - nextEmote = - emoteToObject[words[index]] ?? localEmotes?[words[index]]; - } - - // If there's one more emote thats NOT zero-width, add it to the base of the stack. - if (nextEmote != null) emoteStack.add(nextEmote); - - // Check if the next word is an emoji to be added to the stack. - final nextWordIsEmoji = regexEmoji.hasMatch(words[index]); - - // Create the stack of emotes with the base emoji if there is one. - // Will be used as the children of the Stack widget. - final children = [ - if (nextWordIsEmoji) - Text( - words[index], - style: textStyle?.copyWith(fontSize: emoteSize - 5), - ), - ...emoteStack.reversed.map( - (emote) => FrostyCachedNetworkImage( - imageUrl: emote.url, - height: emote.height != null - ? emote.height! * emoteScale - : emoteSize, - width: emote.width != null - ? emote.width! * emoteScale - : emoteSize, - useFade: false, - ), + span.add( + TextSpan( + text: ' ', + style: onTapDeletedMessage != null + ? textStyle?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, ) - ]; + : null, + recognizer: onTapDeletedMessage != null + ? (TapGestureRecognizer()..onTap = onTapDeletedMessage) + : null, + ), + ); + return; + } - // Create the message for the tooltip - final message = emoteStack.reversed.map( - (emote) => '${emote.name} - ${emoteType[emote.type.index]}'); - localSpan.add( - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Tooltip( - triggerMode: TooltipTriggerMode.tap, - showDuration: const Duration(seconds: 3), - richMessage: WidgetSpan( - child: Column( - children: [ - Stack( - alignment: AlignmentDirectional.center, - children: [ - if (nextWordIsEmoji) - Text( - words[index], - style: textStyle?.copyWith(fontSize: 75), - ), - ...emoteStack.reversed.map( - (emote) => FrostyCachedNetworkImage( - imageUrl: emote.url, - height: 80, - useFade: false, - ), - ) - ], - ), - const SizedBox(height: 5.0), - Column( - children: [ - Text( - nextWordIsEmoji - ? '${words[index]} - Emoji' - : '${emoteStack.last.name} - ${emoteType[emoteStack.last.type.index]}', - style: const TextStyle(color: Colors.white), - ), - Text( - nextWordIsEmoji - ? message.join(', ') - : message.skip(1).join(', '), - style: const TextStyle(color: Colors.white), - ), - ], - ), - ], - ), - ), - preferBelow: false, - child: Stack( - alignment: AlignmentDirectional.center, - children: children, - ), - ), - ), - ); + // Check if the message is a reply. If it is, remove the reply username from the message. + final words = tags.containsKey('reply-parent-display-name') + ? split?.sublist(1) + : split; - // If the next word is neither an emote or emoji, add it as a text span. - if (nextEmote == null && !nextWordIsEmoji) { - localSpan.add(const TextSpan(text: ' ')); - localSpan.add(_createTextSpan( - text: words[index], - style: textStyle, - launchExternal: launchExternal)); - } - } else { - localSpan.add( - _createEmoteSpan( - emote: emote, - height: emote.height != null - ? emote.height! * emoteScale - : emoteSize, - width: emote.width != null ? emote.width! * emoteScale : null, - ), - ); - } + if (words != null) { + // Keep a local span which will be reversed and added to the final span. + final localSpan = []; + + // Iterate through the words in reverse-order. + var index = words.length - 1; + + // Terminate after the first word in the message reached. + while (index != -1) { + final word = words[index]; + final emote = emoteToObject[word] ?? localEmotes?[word]; + + if (emote != null) { + // If the emote is zero-width and not the first word, process the stack. + if (emote.zeroWidth && index != 0) { + index = _processZeroWidthEmoteStack( + context, + localSpan, + words, + index, + emote, + emoteToObject, + emoteSize, + emoteScale, + textStyle, + launchExternal, + linkColor: linkColor, + ); } else { - if (regexEmoji.hasMatch(word)) { - localSpan.add(_createEmojiSpan( - emoji: word, - style: textStyle?.copyWith(fontSize: emoteSize - 5))); - } else { - localSpan.add(_createTextSpan( - text: word, - style: textStyle, - launchExternal: launchExternal)); - } + localSpan.add( + _createEmoteSpan( + context, + emote: emote, + height: emote.height != null + ? emote.height! * emoteScale + : emoteSize, + width: emote.width != null ? emote.width! * emoteScale : null, + launchExternal: launchExternal, + ), + ); + localSpan.add(const TextSpan(text: ' ')); + index--; + } + } else { + if (regexEmoji.hasMatch(word)) { + localSpan.add( + _createEmojiSpan( + emoji: word, + style: textStyle?.copyWith( + fontSize: emoteSize - _emojiSizeOffset, + ), + ), + ); + } else { + localSpan.add( + _createTextSpan( + text: word, + style: textStyle, + linkColor: linkColor, + launchExternal: launchExternal, + onTapPingedUser: onTapPingedUser, + ), + ); } - localSpan.add(const TextSpan(text: ' ')); index--; } - - // Add the the local span reversed to the final span. - span.addAll(localSpan.reversed); } + + // Add the local span reversed to the final span. + span.addAll(localSpan.reversed); } + } + + /// Handles zero-width emote stacking logic and returns updated index + int _processZeroWidthEmoteStack( + BuildContext context, + List localSpan, + List words, + int startIndex, + Emote startEmote, + Map emoteToObject, + double emoteSize, + double emoteScale, + TextStyle? textStyle, + bool launchExternal, { + Color? linkColor, + }) { + final emoteStack = []; + var index = startIndex; + + // Handle stacking consecutive zero-width emotes. + Emote? nextEmote = startEmote; + while (nextEmote != null && nextEmote.zeroWidth && index != 0) { + emoteStack.add(nextEmote); + index--; + nextEmote = emoteToObject[words[index]] ?? localEmotes?[words[index]]; + } + + // If there's one more emote that's NOT zero-width, add it to the base of the stack. + if (nextEmote != null) emoteStack.add(nextEmote); + + // Check if the next word is an emoji to be added to the stack. + final nextWordIsEmoji = regexEmoji.hasMatch(words[index]); + + // Create the stack of emotes with the base emoji if there is one. + final children = [ + if (nextWordIsEmoji) + Text( + words[index], + style: textStyle?.copyWith(fontSize: emoteSize - _emojiSizeOffset), + ), + ...emoteStack.reversed.map( + (emote) => FrostyCachedNetworkImage( + imageUrl: emote.url, + height: emote.height != null ? emote.height! * emoteScale : emoteSize, + width: emote.width != null ? emote.width! * emoteScale : emoteSize, + useFade: false, + ), + ), + ]; + + // Create the message for the tooltip + final message = emoteStack.reversed.map((emote) => emote.name); + final emoji = words[index]; + + localSpan.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: InkWell( + onTap: () => _showAssetDetailsBottomSheet( + context, + leading: Stack( + alignment: AlignmentDirectional.center, + children: [ + if (nextWordIsEmoji) + Text(emoji, style: textStyle?.copyWith(fontSize: 40)), + ...emoteStack.reversed.map( + (emote) => + FrostyCachedNetworkImage(imageUrl: emote.url, width: 56), + ), + ], + ), + url: emoteStack.last.url, + title: nextWordIsEmoji + ? emoji + : '${emoteStack.last.name} (${emoteStack.last.type})', + subtitle: Text( + 'with ${nextWordIsEmoji ? message.join(' + ') : message.skip(1).join(' + ')}', + ), + launchExternal: launchExternal, + ), + child: Stack( + alignment: AlignmentDirectional.center, + children: children, + ), + ), + ), + ); + + // If the next word is neither an emote nor emoji, add it as a text span. + if (nextEmote == null && !nextWordIsEmoji) { + localSpan.add(const TextSpan(text: ' ')); + localSpan.add( + _createTextSpan( + text: words[index], + style: textStyle, + linkColor: linkColor, + launchExternal: launchExternal, + ), + ); + } + + // Add space after the zero-width emote stack (like all other elements) + localSpan.add(const TextSpan(text: ' ')); + + return index - 1; + } + + /// Returns an [InlineSpan] list that corresponds to the badges, username, words, and emotes of the given [IRCMessage]. + List generateSpan( + BuildContext context, { + TextStyle? style, + required ChatAssetsStore assetsStore, + required double badgeScale, + required double emoteScale, + required bool launchExternal, + void Function()? onTapName, + void Function(String)? onTapPingedUser, + void Function()? onTapDeletedMessage, + bool showMessage = true, + Map? 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 = []; + + // When the user has timestamps disabled globally, fall back to the device's + // locale preference for historical messages (opt-in via setting) and for + // forced contexts like previews. + final isHistorical = tags['historical'] == '1'; + final effectiveTimestamp = + (timestamp == TimestampType.disabled && + ((isHistorical && showHistoricalTimestamps) || forceTimestamp)) + ? (MediaQuery.alwaysUse24HourFormatOf(context) + ? TimestampType.twentyFour + : TimestampType.twelve) + : timestamp; + + _addTimestamp(span, style, effectiveTimestamp); + _addHistoricalAndChannelBadges( + context, + span, + badgeSize, + channelIdToUserTwitch, + currentChannelId, + ); + + _addUserBadges( + context, + span, + assetsStore, + badgeSize, + launchExternal, + isHistorical, + ); + + _addUsername(span, context, onTapName); + + // Italicize the text if it was called with an IRC Action (e.g., "/me"). + final textStyle = action == true + ? const TextStyle(fontStyle: FontStyle.italic) + : style; + + _addMessageContent( + context, + span, + emoteToObject, + emoteSize, + emoteScale, + showMessage, + launchExternal, + textStyle, + onTapPingedUser, + onTapDeletedMessage, + linkColor: linkColor, + ); return span; } @@ -496,16 +739,13 @@ class IRCMessage { }) { return WidgetSpan( alignment: PlaceholderAlignment.middle, - child: Text( - emoji, - style: style, - ), + child: Text(emoji, style: style), ); } static Widget _createBadgeWidget({ required ChatBadge badge, - required double size, + double? size, Color? backgroundColor, bool? isSvg, }) { @@ -520,11 +760,7 @@ class IRCMessage { ), ); } else if (isSvg == true) { - return SvgPicture.network( - badge.url, - height: size, - width: size, - ); + return SvgPicture.network(badge.url, height: size, width: size); } else { return FrostyCachedNetworkImage( imageUrl: badge.url, @@ -535,35 +771,31 @@ class IRCMessage { } } - static WidgetSpan _createBadgeSpan({ + static WidgetSpan _createBadgeSpan( + BuildContext context, { required ChatBadge badge, required double size, + required bool launchExternal, Color? backgroundColor, bool? isSvg, }) { return WidgetSpan( alignment: PlaceholderAlignment.middle, - child: Tooltip( - triggerMode: TooltipTriggerMode.tap, - showDuration: const Duration(seconds: 3), - richMessage: WidgetSpan( - child: Column( - children: [ - _createBadgeWidget( - badge: badge, - size: 80, - backgroundColor: backgroundColor, - isSvg: isSvg, - ), - const SizedBox(height: 5.0), - Text( - badge.name, - style: const TextStyle(color: Colors.white), - ), - ], + child: InkWell( + onTap: () => _showAssetDetailsBottomSheet( + context, + leading: _createBadgeWidget( + badge: badge, + backgroundColor: backgroundColor, + isSvg: isSvg, + size: 56, ), + url: badge.url, + title: badge.name, + subtitle: Text(badge.type.toString()), + launchExternal: launchExternal, + showCopyName: false, ), - preferBelow: false, child: _createBadgeWidget( badge: badge, size: size, @@ -574,37 +806,21 @@ class IRCMessage { ); } - static WidgetSpan _createEmoteSpan({ + static WidgetSpan _createEmoteSpan( + BuildContext context, { required Emote emote, required double height, required double? width, + required bool launchExternal, }) { return WidgetSpan( alignment: PlaceholderAlignment.middle, - child: Tooltip( - triggerMode: TooltipTriggerMode.tap, - showDuration: const Duration(seconds: 3), - richMessage: WidgetSpan( - child: Column( - children: [ - FrostyCachedNetworkImage( - imageUrl: emote.url, - height: 80, - useFade: false, - ), - const SizedBox(height: 5.0), - Text( - emote.name, - style: const TextStyle(color: Colors.white), - ), - Text( - emoteType[emote.type.index], - style: const TextStyle(color: Colors.white), - ), - ], - ), + child: InkWell( + onTap: () => showEmoteDetailsBottomSheet( + context, + emote: emote, + launchExternal: launchExternal, ), - preferBelow: false, child: FrostyCachedNetworkImage( imageUrl: emote.url, height: height, @@ -616,26 +832,139 @@ class IRCMessage { ); } - static TextSpan _createTextSpan( - {required String text, required bool launchExternal, TextStyle? style}) { + static TextSpan _createTextSpan({ + required String text, + required bool launchExternal, + TextStyle? style, + Color? linkColor, + Function(String)? onTapPingedUser, + }) { if (text.startsWith('@')) { return TextSpan( - text: text, style: style?.copyWith(fontWeight: FontWeight.bold)); - } else if (RegExp(r'https?:\/\/').hasMatch(text)) { + text: text, + style: style?.copyWith(fontWeight: FontWeight.bold), + recognizer: TapGestureRecognizer() + ..onTap = () { + if (onTapPingedUser != null) { + onTapPingedUser(text.substring(1).split(',')[0]); + } + }, + ); + } else if (regexLink.hasMatch(text)) { return TextSpan( text: text, - style: style?.copyWith(color: Colors.blue), + style: style?.copyWith( + color: linkColor, + decoration: TextDecoration.underline, + decorationColor: linkColor, + ), recognizer: TapGestureRecognizer() - ..onTap = () => launchUrl(Uri.parse(text), - mode: launchExternal - ? LaunchMode.externalApplication - : LaunchMode.inAppWebView), + ..onTap = () => launchUrl( + Uri.parse(text), + mode: launchExternal + ? LaunchMode.externalApplication + : LaunchMode.inAppBrowserView, + ), ); } else { return TextSpan(text: text, style: style); } } + static void showEmoteDetailsBottomSheet( + BuildContext context, { + required Emote emote, + required bool launchExternal, + }) { + _showAssetDetailsBottomSheet( + context, + leading: FrostyCachedNetworkImage(imageUrl: emote.url, width: 56), + url: emote.url, + title: emote.realName != null + ? '${emote.name} (${emote.realName})' + : emote.name, + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(emote.type.toString()), + if (emote.ownerDisplayName != null && emote.ownerUsername != null) + Text( + 'by ${utils.getReadableName(emote.ownerDisplayName!, emote.ownerUsername!)}', + ), + ], + ), + launchExternal: launchExternal, + ); + } + + static void _showAssetDetailsBottomSheet( + BuildContext context, { + required Widget leading, + required String url, + required String title, + required Widget subtitle, + required bool launchExternal, + bool showCopyName = true, + }) { + showModalBottomSheetWithProperFocus( + context: context, + builder: (context) => ListView( + shrinkWrap: true, + primary: false, + children: [ + ListTile( + leading: InkWell( + onTap: () => showDialog( + context: context, + builder: (context) => FrostyPhotoViewDialog(imageUrl: url), + ), + child: leading, + ), + title: Text( + title, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: subtitle, + ), + const Divider(indent: 16, endIndent: 16), + if (showCopyName) + ListTile( + leading: const Icon(Icons.copy_rounded), + title: const Text('Copy name'), + onTap: () { + Clipboard.setData(ClipboardData(text: title.split(' (')[0])); + + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.copy_rounded), + title: const Text('Copy image URL'), + onTap: () { + Clipboard.setData(ClipboardData(text: url)); + + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.launch_rounded), + title: const Text('Open in browser'), + onTap: () { + launchUrl( + Uri.parse(url), + mode: launchExternal + ? LaunchMode.externalApplication + : LaunchMode.inAppBrowserView, + ); + + Navigator.pop(context); + }, + ), + ], + ), + ); + } + /// Parses an IRC string and returns its corresponding [IRCMessage] object. factory IRCMessage.fromString(String whole, {String? userLogin}) { // We have three parts: @@ -649,21 +978,31 @@ class IRCMessage { // Obtain the index to break off the tags. final tagAndIrcMessageDivider = whole.indexOf(' '); - // Get the tags substring and escape characters. - // IRC messages escape spaces with \s. - final tags = - whole.substring(1, tagAndIrcMessageDivider).replaceAll('\\s', ' '); + // 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 {}); + } - // Next, parse and map the tags. + // 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). final mappedTags = {}; + final rawTags = whole.substring(1, tagAndIrcMessageDivider); - // 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; + for (final tag in rawTags.split(';')) { + // Split on the first '=' only — IRCv3 tag values may contain '='. + final equalsIndex = tag.indexOf('='); - final tagSplit = tag.split('='); - mappedTags[tagSplit[0]] = tagSplit[1]; + // 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), + ); } // Now we'll parse the message itself. @@ -677,20 +1016,41 @@ 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 ` `. 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 will be irrelevant. - final String? user = splitMessage[0] == 'tmi.twitch.tv' - ? null - : splitMessage[0].substring(0, splitMessage[0].indexOf('!')); + // 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); + } // If there is an associated message, set it. - // - // Also remove any "INVALID/UNDEFINED" Unicode characters. - // Rendering this character on iOS shows a question mark inside a square. - // This character is used by some clients to bypass restrictions on repeating message. - var message = splitMessage.length > 3 - ? splitMessage.sublist(3).join(' ').substring(1) - : null; + String? message; + if (splitMessage.length > 3) { + final combinedMessage = splitMessage.sublist(3).join(' '); + + if (combinedMessage.startsWith(':')) { + message = combinedMessage.substring(1); + } else { + message = combinedMessage; + } + } // Now process any Twitch emotes contained in the message tags. // The map containing emotes from the user's tags to their URL. @@ -698,22 +1058,26 @@ class IRCMessage { final localEmotes = {}; final emoteTags = mappedTags['emotes']; - if (emoteTags != null) { + if (emoteTags != null && message != null) { // Emotes and their indices are separated by '/' so split them there. final emotes = emoteTags.split('/'); for (final emoteIdAndPosition in emotes) { final indexBetweenIdAndPositions = emoteIdAndPosition.indexOf(':'); - final emoteId = - emoteIdAndPosition.substring(0, indexBetweenIdAndPositions); + final emoteId = emoteIdAndPosition.substring( + 0, + indexBetweenIdAndPositions, + ); - // 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. + // Parse the range to extract the associated word. + // If there is more than one index, use the first. + // Otherwise, use the provided index. final String range; if (emoteIdAndPosition.contains(',')) { range = emoteIdAndPosition.substring( - indexBetweenIdAndPositions + 1, emoteIdAndPosition.indexOf(',')); + indexBetweenIdAndPositions + 1, + emoteIdAndPosition.indexOf(','), + ); } else { range = emoteIdAndPosition.substring(indexBetweenIdAndPositions + 1); } @@ -723,14 +1087,13 @@ class IRCMessage { final startIndex = int.parse(indexSplit[0]); final endIndex = int.parse(indexSplit[1]); - final emoteWord = message!.substring(startIndex, endIndex + 1); + final emoteWord = message.substring(startIndex, endIndex + 1); // Store the emote word and its associated URL for later use. localEmotes[emoteWord] = Emote( name: emoteWord, zeroWidth: false, - url: - 'https://static-cdn.jtvnw.net/emoticons/v2/$emoteId/default/dark/3.0', + url: '$_twitchEmoteBaseUrl/$emoteId$_emoteModeSuffix', type: EmoteType.twitchSub, ); } @@ -741,9 +1104,10 @@ class IRCMessage { List? split; if (message != null) { // Check if IRC actions like "/me" were called. - if (message.startsWith('\x01') && message.endsWith('\x01')) { + if (message.startsWith(_ircActionPrefix) && + message.endsWith(_ircActionSuffix)) { action = true; - message = message.substring(8, message.length - 1); + message = message.substring(_ircActionPrefixLength, message.length - 1); } // Check if the message mentions the logged-in user @@ -754,7 +1118,10 @@ class IRCMessage { // Escape the message message = message .split(' ') - .map((word) => word.replaceAll('\u{E0000}', '').trim()) + // Also remove any "INVALID/UNDEFINED" Unicode characters. + // Rendering this character on iOS shows a question mark inside a square. + // This character is used by some clients to bypass restrictions on repeating message. + .map((word) => word.replaceAll(_invalidUnicodeChar, '').trim()) .where((element) => element != '') .join(' '); @@ -780,7 +1147,8 @@ class IRCMessage { if (wordBuffer.isNotEmpty) { split.addAll( - wordBuffer.toString().split(' ').where((element) => element != '')); + wordBuffer.toString().split(' ').where((element) => element != ''), + ); } if (emojiBuffer.isNotEmpty) split.add(emojiBuffer.toString()); } @@ -831,12 +1199,64 @@ class IRCMessage { ); } - factory IRCMessage.createNotice({required String message}) => IRCMessage( - raw: '', - tags: {}, - command: Command.notice, - message: message, - ); + /// 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}', + }, + command: Command.notice, + message: message, + actionCallback: actionCallback, + actionLabel: actionLabel, + ); } /// The object representation of the IRC ROOMSTATE message. @@ -857,12 +1277,12 @@ class ROOMSTATE { /// Create a new copy with the parameters from the provided [IRCMessage] ROOMSTATE fromIRCMessage(IRCMessage ircMessage) => ROOMSTATE( - emoteOnly: ircMessage.tags['emote-only'] ?? emoteOnly, - followersOnly: ircMessage.tags['followers-only'] ?? followersOnly, - r9k: ircMessage.tags['r9k'] ?? r9k, - slowMode: ircMessage.tags['slow'] ?? slowMode, - subMode: ircMessage.tags['subs-only'] ?? subMode, - ); + emoteOnly: ircMessage.tags['emote-only'] ?? emoteOnly, + followersOnly: ircMessage.tags['followers-only'] ?? followersOnly, + r9k: ircMessage.tags['r9k'] ?? r9k, + slowMode: ircMessage.tags['slow'] ?? slowMode, + subMode: ircMessage.tags['subs-only'] ?? subMode, + ); } class USERSTATE { @@ -881,12 +1301,13 @@ class USERSTATE { }); USERSTATE fromIRCMessage(IRCMessage ircMessage) => USERSTATE( - raw: ircMessage.raw, - color: ircMessage.tags['color'] ?? color, - displayName: ircMessage.tags['display-name'] ?? displayName, - mod: ircMessage.tags['mod'] == '0' ? false : true, - subscriber: ircMessage.tags['subscriber'] == '0' ? false : true, - ); + 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', + ); } /// The possible types of Twitch IRC commands. diff --git a/lib/models/playback_access_token.dart b/lib/models/playback_access_token.dart new file mode 100644 index 00000000..8ca16364 --- /dev/null +++ b/lib/models/playback_access_token.dart @@ -0,0 +1,65 @@ +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 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 json) { + final data = + (json['data'] as Map?)?['streamPlaybackAccessToken'] + as Map?; + if (data == null) { + throw const FormatException( + 'streamPlaybackAccessToken missing from GQL response', + ); + } + + final value = data['value'] as String; + var hideAds = false; + var restricted = const []; + try { + final payload = jsonDecode(value) as Map; + hideAds = payload['hide_ads'] as bool? ?? false; + final bitrates = + (payload['chansub'] as Map?)?['restricted_bitrates'] + as List?; + if (bitrates != null) { + restricted = bitrates + .whereType() + .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, + ); + } +} diff --git a/lib/models/shared_chat_session.dart b/lib/models/shared_chat_session.dart new file mode 100644 index 00000000..fc5c90b5 --- /dev/null +++ b/lib/models/shared_chat_session.dart @@ -0,0 +1,33 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'shared_chat_session.g.dart'; + +@JsonSerializable(fieldRename: FieldRename.snake, createToJson: false) +class SharedChatSession { + final String sessionId; + final String hostBroadcasterId; + final List participants; + final String createdAt; + final String updatedAt; + + SharedChatSession({ + required this.sessionId, + required this.hostBroadcasterId, + required this.participants, + required this.createdAt, + required this.updatedAt, + }); + + factory SharedChatSession.fromJson(Map json) => + _$SharedChatSessionFromJson(json); +} + +@JsonSerializable(fieldRename: FieldRename.snake, createToJson: false) +class Participant { + final String broadcasterId; + + Participant({required this.broadcasterId}); + + factory Participant.fromJson(Map json) => + _$ParticipantFromJson(json); +} diff --git a/lib/models/shared_chat_session.g.dart b/lib/models/shared_chat_session.g.dart new file mode 100644 index 00000000..8148c377 --- /dev/null +++ b/lib/models/shared_chat_session.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'shared_chat_session.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SharedChatSession _$SharedChatSessionFromJson(Map json) => + SharedChatSession( + sessionId: json['session_id'] as String, + hostBroadcasterId: json['host_broadcaster_id'] as String, + participants: (json['participants'] as List) + .map((e) => Participant.fromJson(e as Map)) + .toList(), + createdAt: json['created_at'] as String, + updatedAt: json['updated_at'] as String, + ); + +Participant _$ParticipantFromJson(Map json) => + Participant(broadcasterId: json['broadcaster_id'] as String); diff --git a/lib/models/stream.dart b/lib/models/stream.dart index 05edf653..34a35c60 100644 --- a/lib/models/stream.dart +++ b/lib/models/stream.dart @@ -15,6 +15,14 @@ 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: []) + final List tags; + const StreamTwitch( this.userId, this.userLogin, @@ -24,8 +32,10 @@ class StreamTwitch { this.title, this.viewerCount, this.startedAt, - this.thumbnailUrl, - ); + this.thumbnailUrl, [ + this.language = '', + this.tags = const [], + ]); factory StreamTwitch.fromJson(Map json) => _$StreamTwitchFromJson(json); @@ -36,11 +46,22 @@ class StreamsTwitch { final List data; final Map pagination; - const StreamsTwitch( - this.data, - this.pagination, - ); + const StreamsTwitch(this.data, this.pagination); - factory StreamsTwitch.fromJson(Map json) => - _$StreamsTwitchFromJson(json); + factory StreamsTwitch.fromJson(Map json) { + final rawPagination = json['pagination']; + final pagination = {}; + if (rawPagination is Map) { + final cursor = rawPagination['cursor']; + if (cursor is String && cursor.isNotEmpty) { + pagination['cursor'] = cursor; + } + } + return StreamsTwitch( + (json['data'] as List) + .map((e) => StreamTwitch.fromJson(e as Map)) + .toList(), + pagination, + ); + } } diff --git a/lib/models/stream.g.dart b/lib/models/stream.g.dart index 9c2ae65e..95f9bff3 100644 --- a/lib/models/stream.g.dart +++ b/lib/models/stream.g.dart @@ -7,16 +7,18 @@ part of 'stream.dart'; // ************************************************************************** StreamTwitch _$StreamTwitchFromJson(Map json) => StreamTwitch( - json['user_id'] as String, - json['user_login'] as String, - json['user_name'] as String, - json['game_id'] as String, - json['game_name'] as String, - json['title'] as String, - json['viewer_count'] as int, - json['started_at'] as String, - json['thumbnail_url'] as String, - ); + json['user_id'] as String, + json['user_login'] as String, + json['user_name'] as String, + json['game_id'] as String, + json['game_name'] as String, + json['title'] as String, + (json['viewer_count'] as num).toInt(), + json['started_at'] as String, + json['thumbnail_url'] as String, + json['language'] as String? ?? '', + (json['tags'] as List?)?.map((e) => e as String).toList() ?? [], +); StreamsTwitch _$StreamsTwitchFromJson(Map json) => StreamsTwitch( diff --git a/lib/models/stream_languages.dart b/lib/models/stream_languages.dart new file mode 100644 index 00000000..455ccc60 --- /dev/null +++ b/lib/models/stream_languages.dart @@ -0,0 +1,50 @@ +/// 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 toGqlEnums(Iterable codes) => codes + .map((c) => c.trim().toLowerCase()) + .where((c) => c.isNotEmpty) + .map((c) => c.toUpperCase()) + .toList(); +} diff --git a/lib/models/user.dart b/lib/models/user.dart index 59030217..d97e0fb7 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -10,12 +10,7 @@ class UserTwitch { final String displayName; final String profileImageUrl; - const UserTwitch( - this.id, - this.login, - this.displayName, - this.profileImageUrl, - ); + const UserTwitch(this.id, this.login, this.displayName, this.profileImageUrl); factory UserTwitch.fromJson(Map json) => _$UserTwitchFromJson(json); @@ -27,11 +22,7 @@ class UserBlockedTwitch { final String userLogin; final String displayName; - const UserBlockedTwitch( - this.userId, - this.userLogin, - this.displayName, - ); + const UserBlockedTwitch(this.userId, this.userLogin, this.displayName); factory UserBlockedTwitch.fromJson(Map json) => _$UserBlockedTwitchFromJson(json); diff --git a/lib/models/user.g.dart b/lib/models/user.g.dart index ffeae6d0..e46b0b83 100644 --- a/lib/models/user.g.dart +++ b/lib/models/user.g.dart @@ -7,11 +7,11 @@ part of 'user.dart'; // ************************************************************************** UserTwitch _$UserTwitchFromJson(Map json) => UserTwitch( - json['id'] as String, - json['login'] as String, - json['display_name'] as String, - json['profile_image_url'] as String, - ); + json['id'] as String, + json['login'] as String, + json['display_name'] as String, + json['profile_image_url'] as String, +); UserBlockedTwitch _$UserBlockedTwitchFromJson(Map json) => UserBlockedTwitch( diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index 335d643f..1013812b 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -1,347 +1,822 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:frosty/apis/bttv_api.dart'; -import 'package:frosty/apis/ffz_api.dart'; -import 'package:frosty/apis/seventv_api.dart'; -import 'package:frosty/apis/twitch_api.dart'; -import 'package:frosty/constants.dart'; -import 'package:frosty/main.dart'; -import 'package:frosty/screens/channel/chat/chat.dart'; -import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; -import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; -import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; -import 'package:frosty/screens/channel/video/video.dart'; -import 'package:frosty/screens/channel/video/video_bar.dart'; -import 'package:frosty/screens/channel/video/video_overlay.dart'; -import 'package:frosty/screens/channel/video/video_store.dart'; -import 'package:frosty/screens/settings/stores/auth_store.dart'; -import 'package:frosty/screens/settings/stores/settings_store.dart'; -import 'package:frosty/widgets/app_bar.dart'; -import 'package:frosty/widgets/notification.dart'; -import 'package:provider/provider.dart'; -import 'package:simple_pip_mode/actions/pip_actions_layout.dart'; -import 'package:simple_pip_mode/pip_widget.dart'; - -/// Creates a widget that shows the video stream (if live) and chat of the given user. -class VideoChat extends StatefulWidget { - final String userId; - final String userName; - final String userLogin; - - const VideoChat({ - Key? key, - required this.userId, - required this.userName, - required this.userLogin, - }) : super(key: key); - - @override - State createState() => _VideoChatState(); -} - -class _VideoChatState extends State { - final _videoKey = GlobalKey(); - final _chatKey = GlobalKey(); - - late final ChatStore _chatStore = ChatStore( - channelName: widget.userLogin, - channelId: widget.userId, - displayName: widget.userName, - auth: context.read(), - settings: context.read(), - chatDetailsStore: ChatDetailsStore( - twitchApi: context.read(), - channelName: widget.userLogin, - ), - assetsStore: ChatAssetsStore( - twitchApi: context.read(), - ffzApi: context.read(), - bttvApi: context.read(), - sevenTVApi: context.read(), - ), - ); - - late final VideoStore _videoStore = VideoStore( - userLogin: widget.userLogin, - twitchApi: context.read(), - authStore: context.read(), - settingsStore: context.read(), - ); - - @override - Widget build(BuildContext context) { - final orientation = MediaQuery.of(context).orientation; - - final settingsStore = _chatStore.settings; - - final appBar = FrostyAppBar( - title: Text( - regexEnglish.hasMatch(_chatStore.displayName) - ? _chatStore.displayName - : '${_chatStore.displayName} (${_chatStore.channelName})', - ), - ); - - final player = GestureDetector( - onLongPress: _videoStore.handleToggleOverlay, - child: Video( - key: _videoKey, - videoStore: _videoStore, - ), - ); - - final overlay = GestureDetector( - onLongPress: _videoStore.handleToggleOverlay, - onDoubleTap: orientation == Orientation.landscape - ? () => settingsStore.fullScreen = !settingsStore.fullScreen - : null, - onTap: () { - if (_chatStore.assetsStore.showEmoteMenu) { - _chatStore.assetsStore.showEmoteMenu = false; - } else { - if (_chatStore.textFieldFocusNode.hasFocus) { - _chatStore.textFieldFocusNode.unfocus(); - } else { - _videoStore.handleVideoTap(); - } - } - }, - child: Observer( - builder: (_) { - final videoOverlay = VideoOverlay( - videoStore: _videoStore, - chatStore: _chatStore, - ); - - if (_videoStore.paused || _videoStore.streamInfo == null) { - return videoOverlay; - } - - return AnimatedOpacity( - opacity: _videoStore.overlayVisible ? 1.0 : 0.0, - curve: Curves.ease, - duration: const Duration(milliseconds: 200), - child: Container( - decoration: BoxDecoration( - color: Colors.black.withOpacity(settingsStore.overlayOpacity), - ), - child: IgnorePointer( - ignoring: !_videoStore.overlayVisible, - child: videoOverlay, - ), - ), - ); - }, - ), - ); - - final video = Observer( - builder: (context) { - if (!_videoStore.settingsStore.showOverlay) return player; - - return Stack( - children: [ - player, - overlay, - ], - ); - }, - ); - - final chat = Observer( - builder: (context) { - final videoBarVisible = _videoStore.streamInfo != null && - _chatStore.settings.showVideo && - (_videoStore.paused || _videoStore.overlayVisible); - - return Stack( - children: [ - Chat( - key: _chatKey, - chatStore: _chatStore, - ), - if (orientation == Orientation.portrait) - AnimatedOpacity( - opacity: videoBarVisible ? 1 : 0, - curve: Curves.ease, - duration: const Duration(milliseconds: 200), - child: IgnorePointer( - ignoring: !videoBarVisible, - child: ColoredBox( - color: Theme.of(context).canvasColor, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (_videoStore.streamInfo != null) - VideoBar( - streamInfo: _videoStore.streamInfo!, - tappableCategory: false, - ), - const Divider(height: 1, thickness: 1), - ], - ), - ), - ), - ), - AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeIn, - child: _chatStore.notification != null - ? Align( - alignment: _chatStore.settings.chatNotificationsOnBottom - ? Alignment.bottomCenter - : Alignment.topCenter, - child: FrostyNotification( - message: _chatStore.notification!, - showPasteButton: - _chatStore.notification!.contains('copied'), - onButtonPressed: () async { - // Paste clipboard text into the text controller. - final data = - await Clipboard.getData(Clipboard.kTextPlain); - - if (data != null) { - _chatStore.textController.text = data.text!; - } - - _chatStore.updateNotification(''); - }, - ), - ) - : null, - ), - ], - ); - }, - ); - - final videoChat = Scaffold( - body: Observer( - builder: (context) { - if (orientation == Orientation.landscape && - !settingsStore.landscapeForceVerticalChat) { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); - - final landscapeChat = AnimatedContainer( - curve: Curves.ease, - duration: const Duration(milliseconds: 200), - width: _chatStore.expandChat - ? MediaQuery.of(context).size.width / 2 - : MediaQuery.of(context).size.width * - _chatStore.settings.chatWidth, - color: _chatStore.settings.fullScreen - ? Colors.black.withOpacity( - _chatStore.settings.fullScreenChatOverlayOpacity) - : Theme.of(context).scaffoldBackgroundColor, - child: chat, - ); - - final overlayChat = Visibility( - visible: settingsStore.fullScreenChatOverlay, - maintainState: true, - child: Theme( - data: darkTheme, - child: DefaultTextStyle( - style: DefaultTextStyle.of(context) - .style - .copyWith(color: Colors.white), - child: landscapeChat, - ), - ), - ); - - return ColoredBox( - color: settingsStore.showVideo - ? Colors.black - : Theme.of(context).scaffoldBackgroundColor, - child: SafeArea( - bottom: false, - left: (settingsStore.landscapeCutout == - LandscapeCutoutType.both || - settingsStore.landscapeCutout == - LandscapeCutoutType.left) - ? false - : true, - right: (settingsStore.landscapeCutout == - LandscapeCutoutType.both || - settingsStore.landscapeCutout == - LandscapeCutoutType.right) - ? false - : true, - child: settingsStore.showVideo - ? settingsStore.fullScreen - ? Stack( - children: [ - player, - if (settingsStore.showOverlay) - Row( - children: settingsStore.landscapeChatLeftSide - ? [overlayChat, Expanded(child: overlay)] - : [Expanded(child: overlay), overlayChat], - ) - ], - ) - : Row( - children: settingsStore.landscapeChatLeftSide - ? [landscapeChat, Expanded(child: video)] - : [Expanded(child: video), landscapeChat], - ) - : Column( - children: [appBar, Expanded(child: chat)], - ), - ), - ); - } - - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); - return SafeArea( - child: Column( - children: [ - if (!settingsStore.showVideo) - appBar - else - AspectRatio(aspectRatio: 16 / 9, child: video), - Expanded(child: chat), - ], - ), - ); - }, - ), - ); - - // If on Android, use PiPSwitcher to enable PiP functionality. - if (Platform.isAndroid) { - return PipWidget( - pipLayout: PipActionsLayout.media_only_pause, - onPipAction: (_) => _videoStore.handlePausePlay(), - pipChild: player, - child: videoChat, - ); - } - - return videoChat; - } - - @override - void dispose() { - _chatStore.dispose(); - - _videoStore.dispose(); - - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); - - SystemChrome.setPreferredOrientations([]); - - super.dispose(); - } -} +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; +import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart'; +import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart'; +import 'package:frosty/screens/channel/video/channel_info_header.dart'; +import 'package:frosty/screens/channel/video/native_video.dart'; +import 'package:frosty/screens/channel/video/native_video_store.dart'; +import 'package:frosty/screens/channel/video/stream_info_bar.dart'; +import 'package:frosty/screens/channel/video/video.dart'; +import 'package:frosty/screens/channel/video/video_overlay.dart'; +import 'package:frosty/screens/channel/video/video_player_interface.dart'; +import 'package:frosty/screens/channel/video/video_store.dart'; +import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:frosty/theme.dart'; +import 'package:frosty/utils/context_extensions.dart'; +import 'package:frosty/widgets/blurred_container.dart'; +import 'package:frosty/widgets/draggable_divider.dart'; +import 'package:frosty/widgets/frosty_notification.dart'; +import 'package:simple_pip_mode/actions/pip_actions_layout.dart'; +import 'package:simple_pip_mode/pip_widget.dart'; + +/// Creates a widget that shows the video stream (if live) and chat of the given user. +class VideoChat extends StatefulWidget { + static const routeName = 'VideoChat'; + + final String userId; + final String userName; + final String userLogin; + + const VideoChat({ + super.key, + required this.userId, + required this.userName, + required this.userLogin, + }); + + @override + State createState() => _VideoChatState(); +} + +class _VideoChatState extends State + with SingleTickerProviderStateMixin, WidgetsBindingObserver { + final _videoKey = GlobalKey(); + final _chatKey = GlobalKey(); + + // PiP drag state — ValueNotifier avoids full widget rebuilds during drag. + final _pipDragDistance = ValueNotifier(0); + bool _isPipDragging = false; + bool _isInPipTriggerZone = false; + + // Divider drag state for synchronizing animation + bool _isDividerDragging = false; + + // Essential constants for good UX balance + static const double _pipTriggerDistance = 80; + static const double _pipMaxDragDistance = 150; + + // Animation controller for smooth spring-back + late AnimationController _animationController; + late Animation _springBackAnimation; + + bool? _isImmersiveSystemUiEnabled; + + late final ChatTabsStore _chatTabsStore = ChatTabsStore( + twitchApi: context.twitchApi, + bttvApi: context.bttvApi, + ffzApi: context.ffzApi, + sevenTVApi: context.sevenTVApi, + authStore: context.authStore, + settingsStore: context.settingsStore, + globalAssetsStore: context.globalAssetsStore, + primaryChannelId: widget.userId, + primaryChannelLogin: widget.userLogin, + primaryDisplayName: widget.userName, + ); + + late final VideoPlayerInterface _videoStore = + context.settingsStore.useNativePlayer + ? NativeVideoStore( + userLogin: widget.userLogin, + userId: widget.userId, + displayName: widget.userName, + twitchApi: context.twitchApi, + twitchGqlApi: context.twitchGqlApi, + authStore: context.authStore, + settingsStore: context.settingsStore, + ) + : VideoStore( + userLogin: widget.userLogin, + userId: widget.userId, + twitchApi: context.twitchApi, + authStore: context.authStore, + settingsStore: context.settingsStore, + ); + + @override + void initState() { + super.initState(); + + // Initialize animation controller for smooth drag interactions + _animationController = AnimationController( + duration: const Duration(milliseconds: 250), + vsync: this, + ); + + // Spring-back animation with smooth easing + _springBackAnimation = Tween(begin: 0, end: 0).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeOut), + ); + + // Drive the ValueNotifier from the spring-back animation so the + // AnimatedBuilder rebuilds without a full setState. + _animationController.addListener(() { + _pipDragDistance.value = _springBackAnimation.value; + }); + + // Register as observer for app lifecycle events + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + + if (state == AppLifecycleState.resumed) { + _videoStore.handleAppResume(); + } + } + + void _handlePipDragStart(DragStartDetails details) { + if (_videoStore.isInPipMode || _videoStore.paused || _videoStore.loading) { + return; + } + + _animationController.stop(); + _pipDragDistance.value = 0; + _isInPipTriggerZone = false; + setState(() { + _isPipDragging = true; + }); + } + + void _handlePipDragUpdate(DragUpdateDetails details) { + if (!_isPipDragging || + _videoStore.isInPipMode || + _videoStore.paused || + _videoStore.loading) { + return; + } + + // Update the ValueNotifier directly — only the ListenableBuilder rebuilds, + // not the entire channel scaffold. + _pipDragDistance.value = + (_pipDragDistance.value + details.delta.dy).clamp(0, _pipMaxDragDistance); + + final wasInTriggerZone = _isInPipTriggerZone; + _isInPipTriggerZone = _pipDragDistance.value >= _pipTriggerDistance; + + if (!wasInTriggerZone && _isInPipTriggerZone) { + HapticFeedback.mediumImpact(); + } else if (wasInTriggerZone && !_isInPipTriggerZone) { + HapticFeedback.lightImpact(); + } + } + + void _handlePipDragEnd(DragEndDetails details) { + if (!_isPipDragging || + _videoStore.isInPipMode || + _videoStore.paused || + _videoStore.loading) { + return; + } + + final velocity = details.velocity.pixelsPerSecond.dy; + final shouldTriggerPip = + _pipDragDistance.value >= _pipTriggerDistance || velocity > 600; + + if (shouldTriggerPip) { + HapticFeedback.mediumImpact(); + _videoStore.requestPictureInPicture(); + _resetDragState(); + } else { + _animateSpringBack(); + } + } + + void _handlePipDragCancel() { + if (!_isPipDragging) return; + _animateSpringBack(); + } + + void _resetDragState() { + _pipDragDistance.value = 0; + _isInPipTriggerZone = false; + setState(() { + _isPipDragging = false; + }); + } + + void _animateSpringBack() { + _springBackAnimation = + Tween(begin: _pipDragDistance.value, end: 0).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeOut), + ); + + _animationController.reset(); + _animationController.forward().then((_) { + _resetDragState(); + }); + } + + void _scheduleSystemUiModeSync({required bool immersive}) { + if (_isImmersiveSystemUiEnabled == immersive) return; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _isImmersiveSystemUiEnabled == immersive) return; + + _isImmersiveSystemUiEnabled = immersive; + + if (immersive) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + } else { + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: SystemUiOverlay.values, + ); + } + }); + } + + /// Wraps a video widget with PiP swipe-down gesture handling. + /// + /// Provides visual feedback (translate + scale), haptic feedback, + /// and an instructional overlay during the drag gesture. + Widget _buildPipGestureWrapper({required Widget child, double? aspectRatio}) { + final wrappedChild = aspectRatio != null + ? AspectRatio(aspectRatio: aspectRatio, child: child) + : child; + + // Listen to the ValueNotifier (drag) and AnimationController (spring-back) + // so only this subtree rebuilds during the gesture — not the full scaffold. + return ListenableBuilder( + listenable: _pipDragDistance, + child: wrappedChild, + builder: (context, animatedChild) { + final currentDragDistance = _pipDragDistance.value; + + final scaleFactor = + 1.0 - (currentDragDistance / _pipMaxDragDistance * 0.1); + + return Transform.translate( + offset: Offset(0, currentDragDistance), + child: Transform.scale( + scale: scaleFactor.clamp(0.9, 1.0), + child: Stack( + children: [ + GestureDetector( + onPanStart: _handlePipDragStart, + onPanUpdate: _handlePipDragUpdate, + onPanEnd: _handlePipDragEnd, + onPanCancel: _handlePipDragCancel, + child: animatedChild, + ), + if (!_videoStore.isInPipMode) + Positioned.fill( + child: IgnorePointer( + ignoring: + !(_isPipDragging && currentDragDistance > 0), + child: AnimatedOpacity( + opacity: + (_isPipDragging && currentDragDistance > 0) + ? 1.0 + : 0.0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + child: Container( + color: Colors.black.withValues(alpha: 0.4), + child: const Center( + child: Text( + 'Swipe down to enter picture-in-picture', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } + + /// Convenience getter for the currently active chat store. + ChatStore get _chatStore => _chatTabsStore.activeChatStore; + + @override + Widget build(BuildContext context) { + final settingsStore = _chatTabsStore.settingsStore; + + final player = GestureDetector( + onLongPress: _videoStore.handleToggleOverlay, + child: _videoStore is NativeVideoStore + ? NativeVideo( + // GlobalKey so the native platform view is *reparented* (not + // rebuilt) across the portrait↔landscape layout swap — without + // it, rotating destroys and recreates the player, forcing a + // reload. Mirrors the WebView Video below. + key: _videoKey, + nativeVideoStore: _videoStore, + ) + : Video( + key: _videoKey, + videoStore: _videoStore as VideoStore, + ), + ); + + final overlay = GestureDetector( + onLongPress: _videoStore.handleToggleOverlay, + onDoubleTap: context.isLandscape + ? () => settingsStore.fullScreen = !settingsStore.fullScreen + : null, + onTap: () { + if (_chatStore.assetsStore.showEmoteMenu) { + _chatStore.assetsStore.showEmoteMenu = false; + } else { + if (_chatStore.textFieldFocusNode.hasFocus) { + _chatStore.unfocusInput(); + } else { + _videoStore.handleVideoTap(); + } + } + }, + child: Observer( + builder: (_) { + final videoOverlay = VideoOverlay( + videoStore: _videoStore, + chatStore: _chatStore, + settingsStore: settingsStore, + ); + + if (_videoStore.paused || + _videoStore.streamInfo == null || + _videoStore.loading) { + return videoOverlay; + } + + return AnimatedOpacity( + opacity: _videoStore.overlayVisible ? 1.0 : 0.0, + curve: Curves.ease, + duration: const Duration(milliseconds: 200), + child: ColoredBox( + color: Colors.transparent, + child: IgnorePointer( + ignoring: !_videoStore.overlayVisible, + child: videoOverlay, + ), + ), + ); + }, + ), + ); + + final video = Stack( + children: [ + player, + Positioned.fill( + child: Observer( + builder: (_) => AnimatedOpacity( + opacity: _videoStore.loading ? 1.0 : 0.0, + // Appear instantly to cover native player placeholders during + // recovery; fade out smoothly when playback resumes. + duration: _videoStore.loading + ? Duration.zero + : const Duration(milliseconds: 300), + curve: Curves.easeOut, + // TickerMode stops the spinner's repeating animation while + // hidden — opacity 0 skips painting but would otherwise keep + // scheduling frames for the whole video session. + child: IgnorePointer( + child: TickerMode( + enabled: _videoStore.loading, + child: const ColoredBox( + color: Colors.black, + child: Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + color: Colors.white38, + strokeWidth: 2, + ), + ), + ), + ), + ), + ), + ), + ), + ), + Observer( + builder: (_) => _videoStore.settingsStore.showOverlay + ? overlay + : const SizedBox.shrink(), + ), + ], + ); + + final chat = Observer( + builder: (context) { + final bool chatOnly = !settingsStore.showVideo; + + return Stack( + children: [ + ChatTabs( + key: _chatKey, + chatTabsStore: _chatTabsStore, + // In chat-only mode, account for the blurred AppBar height + listPadding: chatOnly + ? EdgeInsets.only(top: context.safePaddingTop) + : null, + ), + Observer( + builder: (_) => AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: _chatStore.notification != null + ? Align( + alignment: Alignment.topCenter, + child: Padding( + padding: EdgeInsets.only( + top: chatOnly ? context.safePaddingTop : 0, + ), + child: FrostyNotification( + message: _chatStore.notification!, + onDismissed: _chatStore.clearNotification, + ), + ), + ) + : null, + ), + ), + ], + ); + }, + ); + + final videoChat = Observer( + builder: (context) { + _scheduleSystemUiModeSync( + immersive: + context.isLandscape && !settingsStore.landscapeForceVerticalChat, + ); + + // Build a blurred AppBar when in chat-only mode (no video) + PreferredSizeWidget? chatOnlyBlurredAppBar; + if (!settingsStore.showVideo) { + final streamInfo = _videoStore.streamInfo; + + chatOnlyBlurredAppBar = AppBar( + centerTitle: false, + elevation: 0, + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + systemOverlayStyle: SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: + context.theme.brightness == Brightness.dark + ? Brightness.light + : Brightness.dark, + ), + title: StreamInfoBar( + streamInfo: streamInfo, + offlineChannelInfo: _videoStore.offlineChannelInfo, + displayName: _chatStore.displayName, + isCompact: true, + isOffline: streamInfo == null, + isInSharedChatMode: _chatStore.isInSharedChatMode, + showTextShadows: false, + ), + flexibleSpace: BlurredContainer( + gradientDirection: GradientDirection.up, + child: const SizedBox.expand(), + ), + ); + } + + return Scaffold( + extendBody: true, + extendBodyBehindAppBar: true, + appBar: chatOnlyBlurredAppBar, + body: Observer( + builder: (context) { + if (context.isLandscape && + !settingsStore.landscapeForceVerticalChat) { + final landscapeChat = AnimatedContainer( + curve: Curves.ease, + duration: _isDividerDragging + ? Duration.zero + : const Duration(milliseconds: 200), + width: _chatStore.expandChat + ? context.screenWidth / 2 + : context.screenWidth * settingsStore.chatWidth, + color: settingsStore.fullScreen + ? Colors.black.withValues( + alpha: settingsStore.fullScreenChatOverlayOpacity, + ) + : context.scaffoldColor, + child: chat, + ); + + final overlayChat = Visibility( + visible: settingsStore.fullScreenChatOverlay, + maintainState: true, + child: Theme( + data: FrostyThemes( + colorSchemeSeed: Color(settingsStore.accentColor), + ).dark, + child: DefaultTextStyle( + style: context.defaultTextStyle.copyWith( + color: context.frostyColors.overlayOnSurface, + ), + child: landscapeChat, + ), + ), + ); + + return ColoredBox( + color: settingsStore.showVideo + ? Colors.black + : context.scaffoldColor, + child: settingsStore.showVideo + ? settingsStore.fullScreen + ? Stack( + children: [ + _buildPipGestureWrapper(child: player), + if (settingsStore.showOverlay) + LayoutBuilder( + builder: (context, constraints) { + final totalWidth = constraints.maxWidth; + final chatWidth = _chatStore.expandChat + ? 0.5 + : settingsStore.chatWidth; + + final draggableDivider = Observer( + builder: (_) => DraggableDivider( + currentWidth: chatWidth, + maxWidth: 0.6, + isResizableOnLeft: settingsStore + .landscapeChatLeftSide, + showHandle: + _videoStore.overlayVisible && + settingsStore + .fullScreenChatOverlay, + onDragStart: () { + setState(() { + _isDividerDragging = true; + }); + }, + onDrag: (newWidth) { + if (!_chatStore.expandChat) { + settingsStore.chatWidth = + newWidth; + } + }, + onDragEnd: () { + setState(() { + _isDividerDragging = false; + }); + }, + ), + ); + + return Stack( + children: [ + Row( + children: + settingsStore + .landscapeChatLeftSide + ? [ + overlayChat, + Expanded(child: overlay), + ] + : [ + Expanded(child: overlay), + overlayChat, + ], + ), + if (settingsStore + .fullScreenChatOverlay) + Positioned( + top: 0, + bottom: 0, + left: + settingsStore + .landscapeChatLeftSide + ? (totalWidth * chatWidth) - + 12 + : null, + right: + !settingsStore + .landscapeChatLeftSide + ? (totalWidth * chatWidth) - + 12 + : null, + child: draggableDivider, + ), + ], + ); + }, + ), + ], + ) + : SafeArea( + bottom: false, + left: + settingsStore.landscapeCutout != + LandscapeCutoutType.left && + settingsStore.landscapeCutout != + LandscapeCutoutType.both, + right: + settingsStore.landscapeCutout != + LandscapeCutoutType.right && + settingsStore.landscapeCutout != + LandscapeCutoutType.both, + child: LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth; + final chatWidth = _chatStore.expandChat + ? 0.5 + : settingsStore.chatWidth; + + // Create the landscape chat container with proper styling + final chatContainer = AnimatedContainer( + curve: Curves.ease, + duration: _isDividerDragging + ? Duration.zero + : const Duration(milliseconds: 200), + width: availableWidth * chatWidth, + color: settingsStore.fullScreen + ? Colors.black.withValues( + alpha: settingsStore + .fullScreenChatOverlayOpacity, + ) + : context.scaffoldColor, + child: chat, + ); + + final draggableDivider = Observer( + builder: (_) => DraggableDivider( + currentWidth: chatWidth, + maxWidth: 0.6, + isResizableOnLeft: + settingsStore.landscapeChatLeftSide, + showHandle: _videoStore.overlayVisible, + onDragStart: () { + setState(() { + _isDividerDragging = true; + }); + }, + onDrag: (newWidth) { + if (!_chatStore.expandChat) { + settingsStore.chatWidth = newWidth; + } + }, + onDragEnd: () { + setState(() { + _isDividerDragging = false; + }); + }, + ), + ); + + return Stack( + children: [ + Row( + children: + settingsStore + .landscapeChatLeftSide + ? [ + chatContainer, + Expanded( + child: + _buildPipGestureWrapper( + child: video, + ), + ), + ] + : [ + Expanded( + child: + _buildPipGestureWrapper( + child: video, + ), + ), + chatContainer, + ], + ), + Positioned( + top: 0, + bottom: 0, + left: + settingsStore + .landscapeChatLeftSide + ? (availableWidth * chatWidth) - + 12 + : null, + right: + !settingsStore + .landscapeChatLeftSide + ? (availableWidth * chatWidth) - + 12 + : null, + child: draggableDivider, + ), + ], + ); + }, + ), + ) + : SafeArea(child: chat), + ); + } + return SafeArea( + // Keep chat-only scrolling under the blurred app bar, but + // ensure video does not bleed into the system status bar. + top: settingsStore.showVideo, + bottom: false, + child: Stack( + children: [ + Column( + children: [ + if (settingsStore.showVideo) ...[ + AspectRatio( + aspectRatio: 16 / 9, + child: SizedBox(), + ), + ChannelInfoHeader( + videoStore: _videoStore, + authStore: context.authStore, + userId: widget.userId, + displayName: widget.userName, + isInSharedChatMode: + _chatStore.isInSharedChatMode, + ), + ], + Expanded(child: chat), + ], + ), + if (settingsStore.showVideo) + Positioned( + top: 0, + left: 0, + right: 0, + child: _buildPipGestureWrapper( + child: video, + aspectRatio: 16 / 9, + ), + ), + ], + ), + ); + }, + ), + ); + }, + ); + + // If on Android, use PiPSwitcher to enable PiP functionality. + if (Platform.isAndroid) { + // Both players need this — the WebView store used to be skipped by a + // NativeVideoPlayerInterface type check, so it never learned it was in + // PiP on Android. + void notifyPip(bool isInPip) => _videoStore.handleAndroidPipChanged(isInPip); + + return PipWidget( + pipLayout: PipActionsLayout.mediaOnlyPause, + onPipAction: (_) => _videoStore.handlePausePlay(), + onPipEntered: () => notifyPip(true), + onPipExited: () => notifyPip(false), + pipChild: player, + child: videoChat, + ); + } + + return videoChat; + } + + @override + void dispose() { + // Remove observer for app lifecycle events + WidgetsBinding.instance.removeObserver(this); + + _chatTabsStore.dispose(); + + _videoStore.dispose(); + + _animationController.dispose(); + _pipDragDistance.dispose(); + + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: SystemUiOverlay.values, + ); + + SystemChrome.setPreferredOrientations([]); + + super.dispose(); + } +} diff --git a/lib/screens/channel/chat/chat.dart b/lib/screens/channel/chat/chat.dart index f3816682..80ceda50 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -1,19 +1,42 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart'; import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; +import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_bottom_bar.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_message.dart'; -import 'package:frosty/widgets/button.dart'; -import 'package:frosty/widgets/page_view.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; - const Chat({Key? key, required this.chatStore}) : super(key: key); + /// Callback to add a new chat tab. + /// Passes this to ChatBottomBar for the ChatDetails menu. + final VoidCallback onAddChat; + + /// When set, the widget operates in merged mode: messages come from all tabs + /// and the active tab determines the send target. + final ChatTabsStore? chatTabsStore; + + const Chat({ + super.key, + required this.chatStore, + this.listPadding, + required this.onAddChat, + this.chatTabsStore, + }); + + bool get _isMerged => chatTabsStore != null; @override Widget build(BuildContext context) { @@ -22,116 +45,320 @@ class Chat extends StatelessWidget { return Column( children: [ Expanded( - child: GestureDetector( - onTap: () { - if (chatStore.assetsStore.showEmoteMenu) { - chatStore.assetsStore.showEmoteMenu = false; - } else if (chatStore.textFieldFocusNode.hasFocus) { - chatStore.textFieldFocusNode.unfocus(); - } - }, - child: Stack( - alignment: AlignmentDirectional.bottomCenter, - children: [ - MediaQuery( + 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( data: MediaQuery.of(context).copyWith( - textScaleFactor: chatStore.settings.messageScale), + textScaler: chatStore.settings.messageScale.textScaler, + ), child: DefaultTextStyle( - style: DefaultTextStyle.of(context) - .style - .copyWith(fontSize: chatStore.settings.fontSize), - child: Observer( + style: context.defaultTextStyle.copyWith( + fontSize: chatStore.settings.fontSize, + ), + child: Builder( builder: (context) { - return ListView.builder( - reverse: true, - padding: EdgeInsets.zero, - addAutomaticKeepAlives: false, - controller: chatStore.scrollController, - itemCount: chatStore.renderMessages.length, - itemBuilder: (context, index) => ChatMessage( - ircMessage: chatStore.renderMessages.reversed - .toList()[index], - chatStore: chatStore, + // 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: Observer( + builder: (context) { + return _isMerged + ? _buildMergedList( + scrollController, + bottomPadding, + ) + : _buildNormalList( + scrollController, + bottomPadding, + ); + }, + ), ), ); }, ), ), ), - Padding( - padding: const EdgeInsets.all(5.0), - child: Observer( - builder: (_) => AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeIn, - child: chatStore.autoScroll - ? null - : Button( - padding: const EdgeInsets.symmetric( - horizontal: 15.0, vertical: 10.0), - onPressed: chatStore.resumeScroll, - icon: const Icon( - Icons.keyboard_double_arrow_down_rounded), - child: const Text('Resume scroll'), - ), - ), - ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: ChatBottomBar( + chatStore: chatStore, + onAddChat: onAddChat, + channelDisplayName: _isMerged + ? getReadableName( + chatTabsStore!.activeTab.displayName, + chatTabsStore!.activeTab.channelLogin, + ) + : null, ), - ], - ), + ), + _buildResumeScrollButton(), + ], ), ), - if (chatStore.settings.showBottomBar) - ChatBottomBar(chatStore: chatStore), - WillPopScope( - onWillPop: Platform.isAndroid - ? () async { - // If pressing the back button on Android while the emote menu is open, close it instead of going back to the streams list. - if (chatStore.assetsStore.showEmoteMenu) { - chatStore.assetsStore.showEmoteMenu = false; - return false; - } else { - return true; - } - } - : null, - child: AnimatedContainer( - curve: Curves.ease, + _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), - height: chatStore.assetsStore.showEmoteMenu - ? MediaQuery.of(context).size.height / - (MediaQuery.of(context).orientation == - Orientation.portrait - ? 3 - : 2) - : 0, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 100), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeIn, - child: chatStore.assetsStore.showEmoteMenu - ? FrostyPageView( - headers: const [ + 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', - 'Twitch', - '7TV', - 'BTTV', - 'FFZ', + 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, - ), - EmoteMenuPanel( - chatStore: chatStore, - twitchEmotes: chatStore - .assetsStore.userEmoteSectionToEmotes, - ), + RecentEmotesPanel(chatStore: chatStore), + if (chatStore.settings.showTwitchEmotes) + EmoteMenuPanel( + chatStore: chatStore, + twitchEmotes: chatStore + .assetsStore + .userEmoteSectionToEmotes, + ), ...[ - chatStore.assetsStore.sevenTVEmotes, - chatStore.assetsStore.bttvEmotes, - chatStore.assetsStore.ffzEmotes + 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, @@ -139,14 +366,14 @@ class Chat extends StatelessWidget { ), ), ], - ) - : null, + ), + ), + ], + ), ), - ), - ), - ], - ); - }, + ) + : null, + ), ); } } diff --git a/lib/screens/channel/chat/details/chat_details.dart b/lib/screens/channel/chat/details/chat_details.dart index 544a9705..987e15bf 100644 --- a/lib/screens/channel/chat/details/chat_details.dart +++ b/lib/screens/channel/chat/details/chat_details.dart @@ -1,218 +1,621 @@ -import 'dart:ui'; +import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/constants.dart'; import 'package:frosty/main.dart'; +import 'package:frosty/models/irc.dart'; import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; import 'package:frosty/screens/channel/chat/details/chat_modes.dart'; import 'package:frosty/screens/channel/chat/details/chat_users_list.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/settings/settings.dart'; -import 'package:frosty/widgets/bottom_sheet.dart'; -import 'package:frosty/widgets/button.dart'; -import 'package:frosty/widgets/dialog.dart'; -import 'package:frosty/widgets/list_tile.dart'; +import 'package:frosty/utils.dart'; +import 'package:frosty/utils/modal_bottom_sheet.dart'; +import 'package:frosty/widgets/animated_scroll_border.dart'; +import 'package:frosty/widgets/frosty_scrollbar.dart'; +import 'package:frosty/widgets/section_header.dart'; +import 'package:intl/intl.dart'; -class ChatDetails extends StatelessWidget { +class ChatDetails extends StatefulWidget { final ChatDetailsStore chatDetailsStore; final ChatStore chatStore; final String userLogin; + /// Callback to add a new chat tab. + /// Shows an "Add chat" option in the menu. + final VoidCallback onAddChat; + const ChatDetails({ - Key? key, + super.key, required this.chatDetailsStore, required this.chatStore, required this.userLogin, - }) : super(key: key); + required this.onAddChat, + }); - Future _showSleepTimerDialog(BuildContext context) { - return showDialog( + @override + State createState() => _ChatDetailsState(); +} + +class _ChatDetailsState extends State { + late final _scrollController = ScrollController(); + + bool _isRefreshingAssets = false; + bool _showRefreshSuccess = false; + + String formatDuration(Duration duration) { + if (duration.inMinutes < 60) { + return '${duration.inMinutes} ${Intl.plural(duration.inMinutes, one: 'minute', other: 'minutes')}'; + } + + return '${duration.inHours} ${Intl.plural(duration.inHours, one: 'hour', other: 'hours')}'; + } + + String formatTimeLeft(Duration duration) { + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + final seconds = duration.inSeconds.remainder(60); + + if (hours > 0) { + return '${hours}h ${minutes}m'; + } else if (minutes > 0) { + return '${minutes}m ${seconds}s'; + } else { + return '${seconds}s'; + } + } + + Future _showSleepTimer(BuildContext context) { + const durations = [ + Duration(minutes: 5), + Duration(minutes: 10), + Duration(minutes: 15), + Duration(minutes: 30), + Duration(hours: 1), + Duration(hours: 2), + Duration(hours: 3), + Duration(hours: 4), + Duration(hours: 5), + Duration(hours: 6), + Duration(hours: 7), + Duration(hours: 8), + Duration(hours: 9), + Duration(hours: 10), + Duration(hours: 11), + Duration(hours: 12), + ]; + + return showModalBottomSheetWithProperFocus( context: context, - builder: (context) => FrostyDialog( - title: 'Sleep Timer', - content: Observer( - builder: (context) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - Opacity( - opacity: chatStore.sleepTimer != null && - chatStore.sleepTimer!.isActive - ? 1.0 - : 0.5, - child: Row( - children: [ - const Icon(Icons.timer_rounded), - Text( - ' ${chatStore.timeRemaining.toString().split('.')[0]}', - style: const TextStyle( - fontWeight: FontWeight.w600, - fontFeatures: [FontFeature.tabularFigures()]), - ), - const Spacer(), - IconButton( - tooltip: 'Cancel sleep timer', - onPressed: chatStore.cancelSleepTimer, - icon: const Icon(Icons.cancel_rounded), - ), - ], - ), - ), - Row( - children: [ - DropdownButton( - value: chatStore.sleepHours, - items: List.generate(24, (index) => index) - .map((e) => DropdownMenuItem( - value: e, child: Text(e.toString()))) - .toList(), - onChanged: (int? hours) => chatStore.sleepHours = hours!, - menuMaxHeight: 200, - ), - const SizedBox(width: 10.0), - const Text('Hours'), - ], - ), - Row( - children: [ - DropdownButton( - value: chatStore.sleepMinutes, - items: List.generate(60, (index) => index) - .map((e) => DropdownMenuItem( - value: e, child: Text(e.toString()))) - .toList(), - onChanged: (int? minutes) => - chatStore.sleepMinutes = minutes!, - menuMaxHeight: 200, - ), - const SizedBox(width: 10.0), - const Text('Minutes'), - ], - ), - ], + builder: (context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionHeader( + 'Sleep timer', + padding: EdgeInsets.fromLTRB(16, 0, 16, 4), + isFirst: true, ), - ), - actions: [ - Observer( - builder: (context) => Button( - onPressed: - chatStore.sleepHours == 0 && chatStore.sleepMinutes == 0 - ? null - : () => chatStore.updateSleepTimer( - onTimerFinished: () => navigatorKey.currentState - ?.popUntil((route) => route.isFirst), + AnimatedScrollBorder(scrollController: _scrollController), + Expanded( + child: ListView( + controller: _scrollController, + children: [ + if (widget.chatStore.sleepTimer?.isActive == true) + Observer( + builder: (context) { + return ListTile( + leading: const Icon(Icons.close_rounded), + title: Text.rich( + TextSpan( + text: 'Cancel ', + children: [ + TextSpan( + text: formatTimeLeft( + widget.chatStore.timeRemaining, + ), + style: TextStyle( + color: Theme.of(context) + .textTheme + .bodyMedium + ?.color + ?.withValues(alpha: 0.6), + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ], ), - child: const Text('Set timer'), + ), + onTap: () { + widget.chatStore.cancelSleepTimer(); + + Navigator.of(context).pop(); + }, + ); + }, + ), + ...durations.map( + (duration) => ListTile( + leading: const Icon(Icons.hourglass_top_rounded), + title: Text(formatDuration(duration)), + onTap: () { + widget.chatStore.updateSleepTimer( + duration: duration, + onTimerFinished: () => navigatorKey.currentState + ?.popUntil((route) => route.isFirst), + ); + + Navigator.of(context).pop(); + }, + ), + ), + ], ), ), - Button( - onPressed: Navigator.of(context).pop, - color: Colors.grey, - child: const Text('Close'), - ), ], ), ); } - Future _showClearDialog(BuildContext context) { - return showDialog( + /// Converts a hex color string to a color name if it matches one of our predefined chat colors. + /// Returns null if no match is found. + String? _hexToColorName(String hexColor) { + // Remove # if present + final cleanHex = hexColor.startsWith('#') + ? hexColor.substring(1) + : hexColor; + + // Convert to uppercase for comparison + final upperHex = cleanHex.toUpperCase(); + + // Find matching color name + for (final entry in chatColorValues.entries) { + final colorValue = entry.value.toARGB32().toRadixString(16).toUpperCase(); + // Remove alpha channel (first 2 characters) for comparison + if (colorValue.length >= 6) { + final colorHex = colorValue.substring(2); + if (colorHex == upperHex) { + return entry.key; + } + } + } + + return null; + } + + Future _showChatColorPicker(BuildContext context) async { + final selectedColor = await _getCurrentUserColor(); + if (!context.mounted) return; + + showModalBottomSheetWithProperFocus( context: context, - builder: (context) => FrostyDialog( - title: 'Clear Recent Emotes', - message: 'Are you sure you want to clear your recent emotes?', - actions: [ - Button( - onPressed: Navigator.of(context).pop, - child: const Text('Yes'), - ), - Button( - onPressed: Navigator.of(context).pop, - color: Colors.grey, - child: const Text('Cancel'), - ), - ], + builder: (context) => SizedBox( + height: MediaQuery.of(context).size.height * 0.5, + child: _ChatColorPickerModal( + initialColor: selectedColor, + scrollController: _scrollController, + onColorSelected: _handleColorUpdate, + chatStore: widget.chatStore, + ), ), ); } + Future _getCurrentUserColor() async { + try { + final currentColorHex = await widget.chatStore.twitchApi.getUserChatColor( + userId: widget.chatStore.auth.user.details!.id, + ); + + if (currentColorHex.isNotEmpty) { + return _hexToColorName(currentColorHex); + } + } catch (e) { + // Ignore errors when fetching current color + } + return null; + } + + Future _handleColorUpdate(BuildContext context, String color) async { + final success = await widget.chatStore.twitchApi.updateUserChatColor( + userId: widget.chatStore.auth.user.details!.id, + color: color, + ); + + if (!context.mounted) return; + Navigator.of(context).pop(); + + final message = success + ? 'Chat color updated successfully!' + : 'Failed to update chat color. Please try again.'; + _showMessage(context, message); + } + + void _showMessage(BuildContext context, String message) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + bool _hasActiveModes() { + final roomState = widget.chatDetailsStore.roomState; + return roomState.subMode != '0' || + roomState.followersOnly != '-1' || + roomState.emoteOnly != '0' || + roomState.slowMode != '0' || + roomState.r9k != '0'; + } + + Widget _buildRefreshTrailingWidget() { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: _isRefreshingAssets + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ) + : _showRefreshSuccess + ? Icon( + Icons.check_rounded, + color: Theme.of(context).colorScheme.primary, + key: const ValueKey('success'), + ) + : const SizedBox.shrink(), + ); + } + + Future _handleRefreshAssets() async { + setState(() => _isRefreshingAssets = true); + + try { + await widget.chatStore.getAssets(); + if (mounted) { + HapticFeedback.lightImpact(); + setState(() { + _isRefreshingAssets = false; + _showRefreshSuccess = true; + }); + + // Hide success after 2 seconds + Timer(const Duration(seconds: 2), () { + if (mounted) { + setState(() => _showRefreshSuccess = false); + } + }); + } + } catch (e) { + if (mounted) { + setState(() => _isRefreshingAssets = false); + } + } + } + @override Widget build(BuildContext context) { - final children = [ - ListTile( - title: ChatModes(roomState: chatDetailsStore.roomState), - ), - FrostyListTile( - leading: const Icon(Icons.people_outline), - title: 'Chatters', - onTap: () => showModalBottomSheet( - backgroundColor: Colors.transparent, - isScrollControlled: true, - context: context, - builder: (context) => FrostyBottomSheet( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.8, - child: GestureDetector( + return Observer( + builder: (context) { + final hasActiveModes = _hasActiveModes(); + + final children = [ + if (hasActiveModes) + ListTile( + title: SizedBox( + height: 48, + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + Observer( + builder: (context) => ChatModes( + roomState: widget.chatDetailsStore.roomState, + ), + ), + ], + ), + ), + ), + Observer( + builder: (context) { + final showVideo = widget.chatStore.settings.showVideo; + final label = showVideo ? 'Chat only' : 'Show video'; + return ListTile( + leading: Icon( + showVideo ? Icons.chat_rounded : Icons.tv_rounded, + ), + title: Text(label), + onTap: () { + widget.chatStore.settings.showVideo = !showVideo; + Navigator.of(context).pop(); + }, + ); + }, + ), + ListTile( + leading: const Icon(Icons.sync_rounded), + title: const Text('Refresh emotes and badges'), + trailing: _buildRefreshTrailingWidget(), + enabled: !_isRefreshingAssets, + onTap: _isRefreshingAssets ? null : _handleRefreshAssets, + ), + ListTile( + leading: const Icon(Icons.wifi_off_rounded), + title: const Text('Reconnect'), + onTap: () { + Navigator.of(context).pop(); + widget.chatStore.updateNotification('Reconnecting to chat...'); + widget.chatStore.connectToChat(); + }, + ), + if (widget.chatStore.auth.isLoggedIn) + ListTile( + leading: const Icon(Icons.palette_rounded), + title: const Text('Username color'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showChatColorPicker(context), + ), + ListTile( + leading: const Icon(Icons.people_rounded), + title: const Text('Chatters'), + trailing: const Icon(Icons.chevron_right), + onTap: () => showModalBottomSheetWithProperFocus( + isScrollControlled: true, + context: context, + builder: (context) => GestureDetector( onTap: FocusScope.of(context).unfocus, child: ChattersList( - chatDetailsStore: chatDetailsStore, - chatStore: chatStore, - userLogin: userLogin, + chatDetailsStore: widget.chatDetailsStore, + chatStore: widget.chatStore, + userLogin: widget.userLogin, ), ), ), ), + ListTile( + leading: const Icon(Icons.add_comment_rounded), + title: const Text('Add chat'), + trailing: const Icon(Icons.chevron_right), + onTap: widget.onAddChat, + ), + Observer( + builder: (context) { + final hasTimer = widget.chatStore.timeRemaining.inSeconds > 0; + final label = hasTimer + ? formatTimeLeft(widget.chatStore.timeRemaining) + : 'Sleep timer'; + return ListTile( + leading: Icon( + hasTimer ? Icons.timer_rounded : Icons.timer_rounded, + color: hasTimer + ? Theme.of(context).colorScheme.primary + : null, + ), + title: Text( + label, + style: hasTimer + ? TextStyle( + fontFeatures: const [FontFeature.tabularFigures()], + color: Theme.of(context).colorScheme.primary, + ) + : null, + ), + trailing: hasTimer + ? Icon( + Icons.chevron_right, + color: Theme.of(context).colorScheme.primary, + ) + : const Icon(Icons.chevron_right), + onTap: () => _showSleepTimer(context), + ); + }, + ), + ListTile( + leading: const Icon(Icons.settings_rounded), + title: const Text('Settings'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: Settings.routeName), + builder: (context) => + Settings(settingsStore: widget.chatStore.settings), + ), + ), + ), + ]; + + return ListView(shrinkWrap: true, primary: false, children: children); + }, + ); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } +} + +class _ChatColorPickerModal extends StatefulWidget { + const _ChatColorPickerModal({ + required this.initialColor, + required this.scrollController, + required this.onColorSelected, + required this.chatStore, + }); + + final String? initialColor; + final ScrollController scrollController; + final Future Function(BuildContext context, String color) + onColorSelected; + final ChatStore chatStore; + + @override + State<_ChatColorPickerModal> createState() => _ChatColorPickerModalState(); +} + +class _ChatColorPickerModalState extends State<_ChatColorPickerModal> { + String? selectedColor; + + @override + void initState() { + super.initState(); + selectedColor = widget.initialColor; + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(), + _buildPreview(), + AnimatedScrollBorder(scrollController: widget.scrollController), + _buildColorList(), + _buildBottomBar(), + ], + ); + } + + Widget _buildHeader() { + return const SectionHeader('Username color', isFirst: true); + } + + Widget _buildPreview() { + if (selectedColor == null) return const SizedBox.shrink(); + + // Create a realistic preview using the current user's chat state and badges + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: _buildRealisticChatPreview(), + ); + } + + Widget _buildRealisticChatPreview() { + final userDetails = widget.chatStore.auth.user.details!; + final userState = widget.chatStore.userState; + + // Create a mock IRC message with the user's current state + final mockTags = { + 'display-name': userDetails.displayName, + 'color': + '#${chatColorValues[selectedColor!]!.toARGB32().toRadixString(16).substring(2)}', + 'user-id': userDetails.id, + 'mod': userState.mod ? '1' : '0', + 'subscriber': userState.subscriber ? '1' : '0', + 'id': 'preview-message', + 'tmi-sent-ts': DateTime.now().millisecondsSinceEpoch.toString(), + }; + + // Copy any badges from the current user state + if (userState.raw != null) { + final userStateMessage = IRCMessage.fromString(userState.raw!); + if (userStateMessage.tags['badges'] != null) { + mockTags['badges'] = userStateMessage.tags['badges']!; + } + if (userStateMessage.tags['badge-info'] != null) { + mockTags['badge-info'] = userStateMessage.tags['badge-info']!; + } + } + + final mockMessage = IRCMessage( + raw: '', + command: Command.privateMessage, + tags: mockTags, + user: userDetails.login, + message: 'How it will look in chat', + split: ['How', 'it', 'will', 'look', 'in', 'chat'], + action: false, + mention: false, + ); + + return Text.rich( + TextSpan( + children: mockMessage.generateSpan( + context, + assetsStore: widget.chatStore.assetsStore, + emoteScale: widget.chatStore.settings.emoteScale, + badgeScale: widget.chatStore.settings.badgeScale, + launchExternal: false, // Disable launching for preview + style: DefaultTextStyle.of(context).style, ), ), - FrostyListTile( - leading: const Icon(Icons.timer_outlined), - title: 'Sleep timer', - onTap: () => _showSleepTimerDialog(context), - ), - FrostyListTile( - leading: const Icon(Icons.delete_outline_rounded), - title: 'Clear recent emotes', - onTap: () => _showClearDialog(context), - ), - FrostyListTile( - leading: const Icon(Icons.refresh_rounded), - title: 'Reconnect to chat', - onTap: () { - chatStore.updateNotification('Reconnecting to chat...'); + ); + } - chatStore.connectToChat(); - }, + Widget _buildColorList() { + return Expanded( + child: FrostyScrollbar( + controller: widget.scrollController, + child: ListView( + padding: EdgeInsets.zero, + controller: widget.scrollController, + children: chatColorNames + .map((colorName) => _buildColorTile(colorName)) + .toList(), + ), ), - FrostyListTile( - leading: const Icon(Icons.refresh_rounded), - title: 'Refresh badges and emotes', - onTap: () async { - await chatStore.getAssets(); + ); + } - chatStore.updateNotification('Badges and emotes refreshed'); - }, + Widget _buildColorTile(String colorName) { + final isSelected = selectedColor == colorName; + final displayName = colorName + .replaceAll('_', ' ') + .split(' ') + .map((word) => word[0].toUpperCase() + word.substring(1)) + .join(' '); + + // Apply the same color adjustment for consistent preview + final rawColor = chatColorValues[colorName]!; + final adjustedColor = adjustChatNameColor(context, rawColor); + + return ListTile( + leading: Container( + width: 24, + height: 24, + decoration: BoxDecoration(color: adjustedColor, shape: BoxShape.circle), ), - FrostyListTile( - leading: const Icon(Icons.settings_outlined), - title: 'Settings', - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => Settings(settingsStore: chatStore.settings), + title: Text(displayName), + trailing: isSelected + ? Icon(Icons.check, color: Theme.of(context).colorScheme.primary) + : null, + onTap: () => setState(() => selectedColor = colorName), + ); + } + + Widget _buildBottomBar() { + return Column( + children: [ + AnimatedScrollBorder( + scrollController: widget.scrollController, + position: ScrollBorderPosition.bottom, + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Expanded( + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton( + onPressed: selectedColor == null + ? null + : () => widget.onColorSelected(context, selectedColor!), + child: const Text('Save'), + ), + ), + ], + ), ), ), - ), - ]; - - return FrostyBottomSheet( - child: MediaQuery.of(context).orientation == Orientation.landscape - ? SizedBox( - height: MediaQuery.of(context).size.height * 0.8, - child: ListView( - children: children, - ), - ) - : Column( - crossAxisAlignment: CrossAxisAlignment.start, children: children), + ], ); } } diff --git a/lib/screens/channel/chat/details/chat_details_store.g.dart b/lib/screens/channel/chat/details/chat_details_store.g.dart index 6cd8e900..cc6ee138 100644 --- a/lib/screens/channel/chat/details/chat_details_store.g.dart +++ b/lib/screens/channel/chat/details/chat_details_store.g.dart @@ -12,13 +12,16 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store { Computed>? _$filteredUsersComputed; @override - Iterable get filteredUsers => (_$filteredUsersComputed ??= - Computed>(() => super.filteredUsers, - name: 'ChatDetailsStoreBase.filteredUsers')) - .value; + Iterable get filteredUsers => + (_$filteredUsersComputed ??= Computed>( + () => super.filteredUsers, + name: 'ChatDetailsStoreBase.filteredUsers', + )).value; - late final _$roomStateAtom = - Atom(name: 'ChatDetailsStoreBase.roomState', context: context); + late final _$roomStateAtom = Atom( + name: 'ChatDetailsStoreBase.roomState', + context: context, + ); @override ROOMSTATE get roomState { @@ -33,8 +36,10 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store { }); } - late final _$showJumpButtonAtom = - Atom(name: 'ChatDetailsStoreBase.showJumpButton', context: context); + late final _$showJumpButtonAtom = Atom( + name: 'ChatDetailsStoreBase.showJumpButton', + context: context, + ); @override bool get showJumpButton { @@ -49,8 +54,10 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store { }); } - late final _$_filterTextAtom = - Atom(name: 'ChatDetailsStoreBase._filterText', context: context); + late final _$_filterTextAtom = Atom( + name: 'ChatDetailsStoreBase._filterText', + context: context, + ); String get filterText { _$_filterTextAtom.reportRead(); diff --git a/lib/screens/channel/chat/details/chat_modes.dart b/lib/screens/channel/chat/details/chat_modes.dart index 573c192f..403381dc 100644 --- a/lib/screens/channel/chat/details/chat_modes.dart +++ b/lib/screens/channel/chat/details/chat_modes.dart @@ -1,87 +1,183 @@ 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; - const ChatModes({Key? key, required this.roomState}) : super(key: key); + const ChatModes({super.key, required this.roomState}); String pluralize(String str, String count) => count == '1' ? str : '${str}s'; @override Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Tooltip( - preferBelow: false, - triggerMode: TooltipTriggerMode.tap, - showDuration: const Duration(seconds: 3), - message: - 'Emote-only mode ${roomState.emoteOnly != '0' ? 'on' : 'off'}', - child: Icon( - roomState.emoteOnly != '0' - ? Icons.emoji_emotions_rounded - : Icons.emoji_emotions_outlined, - color: roomState.emoteOnly != '0' ? Colors.yellow : Colors.grey, - ), - ), - Tooltip( - preferBelow: false, - triggerMode: TooltipTriggerMode.tap, - showDuration: const Duration(seconds: 3), - message: roomState.followersOnly == '-1' - ? 'Followers-only mode off' - : roomState.followersOnly == '0' - ? 'Followers-only mode on' - : 'Followers-only mode on (${roomState.followersOnly} ${pluralize('minute', roomState.followersOnly)})', - child: Icon( - roomState.followersOnly != '-1' - ? Icons.favorite_rounded - : Icons.favorite_outline_rounded, - color: roomState.followersOnly != '-1' ? Colors.red : Colors.grey, - ), - ), - Tooltip( - preferBelow: false, - triggerMode: TooltipTriggerMode.tap, - showDuration: const Duration(seconds: 3), - message: - 'R9K (unique-chat) mode ${roomState.r9k != '0' ? 'on' : 'off'}', - child: Text( - 'R9K', - style: TextStyle( - color: roomState.r9k != '0' ? Colors.purple : Colors.grey, - fontWeight: FontWeight.w500, + return Observer( + builder: (context) { + final activeModes = >[]; + + // Collect active modes with their labels for sorting + if (roomState.emoteOnly != '0') { + activeModes.add( + MapEntry( + 'Emote only', + _buildModeChip( + context: context, + icon: Icons.emoji_emotions_outlined, + activeIcon: Icons.emoji_emotions_rounded, + label: 'Emote only', + activeLabel: 'Emote only', + isActive: true, + activeColor: context.frostyColors.emoteOnly, + ), ), - ), - ), - Tooltip( - preferBelow: false, - triggerMode: TooltipTriggerMode.tap, - showDuration: const Duration(seconds: 3), - message: - 'Slow mode ${roomState.slowMode != '0' ? 'on (${roomState.slowMode} ${pluralize('second', roomState.slowMode)})' : 'off'}', - child: Icon( - roomState.slowMode != '0' - ? Icons.hourglass_top_rounded - : Icons.hourglass_empty_rounded, - color: roomState.slowMode != '0' ? Colors.blue : Colors.grey, - ), - ), - Tooltip( - preferBelow: false, - triggerMode: TooltipTriggerMode.tap, - showDuration: const Duration(seconds: 3), - message: 'Subs-only mode ${roomState.subMode != '0' ? 'on' : 'off'}', - child: Icon( - roomState.subMode != '0' - ? Icons.monetization_on_rounded - : Icons.monetization_on_outlined, - color: roomState.subMode != '0' ? Colors.green : Colors.grey, - ), - ), - ], + ); + } + + if (roomState.followersOnly != '-1') { + activeModes.add( + MapEntry( + 'Follower only', + _buildModeChip( + context: context, + icon: Icons.favorite_outline_rounded, + activeIcon: Icons.favorite_rounded, + label: 'Follower only', + activeLabel: 'Follower only', + isActive: true, + activeColor: context.frostyColors.followersOnly, + duration: _getFollowersDuration(), + ), + ), + ); + } + + if (roomState.slowMode != '0') { + activeModes.add( + MapEntry( + 'Slow mode', + _buildModeChip( + context: context, + icon: Icons.hourglass_empty_rounded, + activeIcon: Icons.hourglass_top_rounded, + label: 'Slow mode', + activeLabel: 'Slow mode', + isActive: true, + activeColor: context.frostyColors.slowMode, + duration: _getSlowModeDuration(), + ), + ), + ); + } + + if (roomState.subMode != '0') { + activeModes.add( + MapEntry( + 'Sub only', + _buildModeChip( + context: context, + icon: Icons.monetization_on_outlined, + activeIcon: Icons.monetization_on_rounded, + label: 'Sub only', + activeLabel: 'Sub only', + isActive: true, + activeColor: context.frostyColors.subOnly, + ), + ), + ); + } + + if (roomState.r9k != '0') { + activeModes.add( + MapEntry( + 'Unique mode', + _buildModeChip( + context: context, + icon: Icons.quiz_outlined, + activeIcon: Icons.quiz_rounded, + label: 'Unique mode', + activeLabel: 'Unique mode', + isActive: true, + activeColor: context.frostyColors.uniqueMode, + ), + ), + ); + } + + // Sort alphabetically by label + activeModes.sort((a, b) => a.key.compareTo(b.key)); + final activeChips = activeModes.map((entry) => entry.value).toList(); + + return Wrap(spacing: 8, runSpacing: -4, children: activeChips); + }, + ); + } + + String? _getFollowersDuration() { + if (roomState.followersOnly == '0') { + return null; // No duration for immediate followers + } + final minutes = int.parse(roomState.followersOnly); + if (minutes >= 60) { + final hours = minutes ~/ 60; + final remainingMinutes = minutes % 60; + if (remainingMinutes == 0) { + return '${hours}h'; + } else { + return '${hours}h ${remainingMinutes}m'; + } + } + return '${minutes}m'; + } + + String _getSlowModeDuration() { + final seconds = int.parse(roomState.slowMode); + if (seconds >= 60) { + final minutes = seconds ~/ 60; + final remainingSeconds = seconds % 60; + if (remainingSeconds == 0) { + return '${minutes}m'; + } else { + return '${minutes}m ${remainingSeconds}s'; + } + } + return '${seconds}s'; + } + + Widget _buildModeChip({ + required BuildContext context, + required IconData icon, + required IconData activeIcon, + required String label, + required String activeLabel, + required bool isActive, + required Color activeColor, + String? duration, + }) { + return Chip( + avatar: Icon( + isActive ? activeIcon : icon, + size: 16, + color: isActive ? activeColor : null, + ), + label: duration != null + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(isActive ? activeLabel : label), + const SizedBox(width: 4), + Text( + duration, + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + ], + ) + : Text(isActive ? activeLabel : label), + side: BorderSide.none, ); } } diff --git a/lib/screens/channel/chat/details/chat_users_list.dart b/lib/screens/channel/chat/details/chat_users_list.dart index 5eabc983..ddaabb1c 100644 --- a/lib/screens/channel/chat/details/chat_users_list.dart +++ b/lib/screens/channel/chat/details/chat_users_list.dart @@ -5,8 +5,10 @@ import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart'; -import 'package:frosty/screens/settings/stores/auth_store.dart'; +import 'package:frosty/utils/modal_bottom_sheet.dart'; import 'package:frosty/widgets/alert_message.dart'; +import 'package:frosty/widgets/animated_scroll_border.dart'; +import 'package:frosty/widgets/frosty_scrollbar.dart'; import 'package:frosty/widgets/scroll_to_top_button.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; @@ -17,11 +19,11 @@ class ChattersList extends StatefulWidget { final String userLogin; const ChattersList({ - Key? key, + super.key, required this.chatDetailsStore, required this.chatStore, required this.userLogin, - }) : super(key: key); + }); @override State createState() => _ChattersListState(); @@ -30,155 +32,163 @@ class ChattersList extends StatefulWidget { class _ChattersListState extends State { @override Widget build(BuildContext context) { - return Column( - children: [ - Padding( - padding: const EdgeInsets.all(10.0), - child: Observer( - builder: (context) { - return TextField( - controller: widget.chatDetailsStore.textController, - focusNode: widget.chatDetailsStore.textFieldFocusNode, - autocorrect: false, - decoration: InputDecoration( - contentPadding: EdgeInsets.zero, - prefixIcon: const Icon(Icons.filter_list_rounded), - hintText: 'Filter chatters', - suffixIcon: widget - .chatDetailsStore.textFieldFocusNode.hasFocus || - widget.chatDetailsStore.filterText.isNotEmpty - ? IconButton( - tooltip: widget.chatDetailsStore.filterText.isEmpty - ? 'Cancel' - : 'Clear', - onPressed: () { - if (widget.chatDetailsStore.filterText.isEmpty) { - widget.chatDetailsStore.textFieldFocusNode - .unfocus(); - } - widget.chatDetailsStore.textController.clear(); - }, - icon: const Icon(Icons.close_rounded), - ) - : null, - ), - ); - }, - ), - ), - Expanded( - child: RefreshIndicator( - onRefresh: () async { - HapticFeedback.lightImpact(); - - setState(() {}); - }, - child: Stack( - alignment: AlignmentDirectional.bottomCenter, - children: [ - Observer( - builder: (context) { - return CustomScrollView( - physics: const AlwaysScrollableScrollPhysics(), - controller: widget.chatDetailsStore.scrollController, - slivers: [ - if (widget.chatDetailsStore.filterText.isEmpty) - SliverPadding( - padding: const EdgeInsets.only( - left: 10.0, - right: 10.0, - top: 20.0, - bottom: 10.0, - ), - sliver: SliverToBoxAdapter( - child: Text( - '${NumberFormat().format(widget.chatDetailsStore.chatUsers.length)} chatters found', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18.0, - ), - ), - ), - ), - if (widget.chatDetailsStore.chatUsers.isEmpty) - const SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: AlertMessage(message: 'No chatters found'), - ), + return SizedBox( + height: MediaQuery.of(context).size.height * 0.8, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: Observer( + builder: (context) { + return TextField( + controller: widget.chatDetailsStore.textController, + focusNode: widget.chatDetailsStore.textFieldFocusNode, + autocorrect: false, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.filter_list_rounded), + hintText: 'Filter chatters', + suffixIcon: + widget.chatDetailsStore.textFieldFocusNode.hasFocus || + widget.chatDetailsStore.filterText.isNotEmpty + ? IconButton( + tooltip: widget.chatDetailsStore.filterText.isEmpty + ? 'Cancel' + : 'Clear', + onPressed: () { + if (widget.chatDetailsStore.filterText.isEmpty) { + widget.chatDetailsStore.textFieldFocusNode + .unfocus(); + } + widget.chatDetailsStore.textController.clear(); + }, + icon: const Icon(Icons.close_rounded), ) - else if (widget.chatDetailsStore.filteredUsers.isEmpty) - const SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: - AlertMessage(message: 'No matching chatters'), - ), - ) - else - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) => InkWell( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10.0, vertical: 5.0), - child: Text(widget - .chatDetailsStore.filteredUsers - .elementAt(index)), - ), - onLongPress: () async { - HapticFeedback.lightImpact(); - - final userInfo = await context - .read() - .getUser( - headers: context - .read() - .headersTwitch, - userLogin: widget - .chatDetailsStore.filteredUsers - .elementAt(index)); - - if (!mounted) return; - - showModalBottomSheet( - backgroundColor: Colors.transparent, - isScrollControlled: true, - context: context, - builder: (context) => ChatUserModal( - chatStore: widget.chatStore, - username: userInfo.login, - userId: userInfo.id, - displayName: userInfo.displayName, - ), - ); - }, - ), - childCount: - widget.chatDetailsStore.filteredUsers.length, - ), - ), - ], - ); - }, - ), - Observer( - builder: (context) => AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeIn, - child: widget.chatDetailsStore.showJumpButton - ? ScrollToTopButton( - scrollController: - widget.chatDetailsStore.scrollController) : null, ), - ), - ], + ); + }, ), ), - ), - ], + AnimatedScrollBorder( + scrollController: widget.chatDetailsStore.scrollController, + ), + Expanded( + child: RefreshIndicator.adaptive( + onRefresh: () async { + HapticFeedback.lightImpact(); + + setState(() {}); + }, + child: Stack( + alignment: AlignmentDirectional.bottomCenter, + children: [ + FrostyScrollbar( + controller: widget.chatDetailsStore.scrollController, + child: Observer( + builder: (context) { + return CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + controller: widget.chatDetailsStore.scrollController, + slivers: [ + if (widget.chatDetailsStore.filterText.isEmpty) + SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + sliver: SliverToBoxAdapter( + child: Text( + '${NumberFormat().format(widget.chatDetailsStore.chatUsers.length)} chatters found', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18.0, + ), + ), + ), + ), + if (widget.chatDetailsStore.chatUsers.isEmpty) + const SliverFillRemaining( + hasScrollBody: false, + child: AlertMessage( + message: 'No chatters found', + ), + ) + else if (widget + .chatDetailsStore + .filteredUsers + .isEmpty) + const SliverFillRemaining( + hasScrollBody: false, + child: AlertMessage( + message: 'No matching chatters', + ), + ) + else + SliverList.builder( + itemBuilder: (context, index) => InkWell( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + child: Text( + widget.chatDetailsStore.filteredUsers + .elementAt(index), + ), + ), + onTap: () async { + final userInfo = await context + .read() + .getUser( + userLogin: widget + .chatDetailsStore + .filteredUsers + .elementAt(index), + ); + + if (!context.mounted) return; + + showModalBottomSheetWithProperFocus( + isScrollControlled: true, + context: context, + builder: (context) => ChatUserModal( + chatStore: widget.chatStore, + username: userInfo.login, + userId: userInfo.id, + displayName: userInfo.displayName, + ), + ); + }, + ), + itemCount: widget + .chatDetailsStore + .filteredUsers + .length, + ), + ], + ); + }, + ), + ), + Observer( + builder: (context) => AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: widget.chatDetailsStore.showJumpButton + ? ScrollToTopButton( + scrollController: + widget.chatDetailsStore.scrollController, + ) + : null, + ), + ), + ], + ), + ), + ), + ], + ), ); } diff --git a/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart b/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart index f6a18f93..20c1f2bc 100644 --- a/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart +++ b/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:frosty/models/emotes.dart'; import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_section.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; -import 'package:frosty/widgets/page_view.dart'; +import 'package:frosty/widgets/frosty_page_view.dart'; class EmoteMenuPanel extends StatelessWidget { final ChatStore chatStore; @@ -10,50 +10,61 @@ class EmoteMenuPanel extends StatelessWidget { final Map>? twitchEmotes; const EmoteMenuPanel({ - Key? key, + super.key, required this.chatStore, this.emotes, this.twitchEmotes, - }) : super(key: key); + }); @override Widget build(BuildContext context) { if (emotes != null) { final globalEmotes = emotes! - .where((emote) => - emote.type == EmoteType.bttvGlobal || - emote.type == EmoteType.ffzGlobal || - emote.type == EmoteType.sevenTVGlobal) + .where( + (emote) => + emote.type == EmoteType.bttvGlobal || + emote.type == EmoteType.ffzGlobal || + emote.type == EmoteType.sevenTVGlobal, + ) .toList(); final channelEmotes = emotes! - .where((emote) => - emote.type == EmoteType.bttvChannel || - emote.type == EmoteType.bttvShared || - emote.type == EmoteType.ffzChannel || - emote.type == EmoteType.sevenTVChannel) + .where( + (emote) => + emote.type == EmoteType.bttvChannel || + emote.type == EmoteType.bttvShared || + emote.type == EmoteType.ffzChannel || + emote.type == EmoteType.sevenTVChannel, + ) .toList(); return FrostyPageView( - headers: ['Global', if (channelEmotes.isNotEmpty) 'Channel'], + headers: [if (channelEmotes.isNotEmpty) 'Channel', 'Global'], children: [ - EmoteMenuSection( - chatStore: chatStore, - emotes: globalEmotes, - ), if (channelEmotes.isNotEmpty) - EmoteMenuSection( - chatStore: chatStore, - emotes: channelEmotes, - ), + EmoteMenuSection(chatStore: chatStore, emotes: channelEmotes), + EmoteMenuSection(chatStore: chatStore, emotes: globalEmotes), ], ); } else { + final isSubbed = chatStore.userState.subscriber; + + twitchEmotes?.removeWhere( + (key, value) => key.toLowerCase() == chatStore.channelName, + ); + return FrostyPageView( - headers: - twitchEmotes!.keys.map((header) => header.split(' ')[0]).toList(), + headers: twitchEmotes!.keys + .map((header) => header.split(' ')[0]) + .toList(), children: twitchEmotes!.entries - .map((e) => EmoteMenuSection(chatStore: chatStore, emotes: e.value)) + .map( + (e) => EmoteMenuSection( + chatStore: chatStore, + emotes: e.value, + disabled: e.key.contains('Channel') && !isSubbed, + ), + ) .toList(), ); } diff --git a/lib/screens/channel/chat/emote_menu/emote_menu_section.dart b/lib/screens/channel/chat/emote_menu/emote_menu_section.dart index 2bb49284..52005c0c 100644 --- a/lib/screens/channel/chat/emote_menu/emote_menu_section.dart +++ b/lib/screens/channel/chat/emote_menu/emote_menu_section.dart @@ -1,50 +1,80 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/models/emotes.dart'; +import 'package:frosty/models/irc.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; -import 'package:frosty/screens/settings/stores/settings_store.dart'; -import 'package:frosty/widgets/cached_image.dart'; -import 'package:provider/provider.dart'; +import 'package:frosty/utils/context_extensions.dart'; +import 'package:frosty/widgets/frosty_cached_network_image.dart'; -class EmoteMenuSection extends StatelessWidget { +class EmoteMenuSection extends StatefulWidget { final ChatStore chatStore; final List emotes; + final bool disabled; const EmoteMenuSection({ - Key? key, + super.key, required this.chatStore, required this.emotes, - }) : super(key: key); + this.disabled = false, + }); + @override + State createState() => _EmoteMenuSectionState(); +} + +class _EmoteMenuSectionState extends State + with AutomaticKeepAliveClientMixin { @override Widget build(BuildContext context) { - return GridView.builder( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: - MediaQuery.of(context).orientation == Orientation.portrait - ? 8 - : context.read().showVideo - ? 6 - : 16, - ), - itemBuilder: (context, index) => InkWell( - onTap: () => chatStore.addEmote(emotes[index]), - child: Tooltip( - message: emotes[index].name, - preferBelow: false, + super.build(context); + + return MediaQuery.removePadding( + context: context, + removeTop: true, + child: GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: context.isPortrait + ? 8 + : context.settingsStore.showVideo + ? 6 + : 16, + ), + padding: EdgeInsets.zero, + itemBuilder: (context, index) => InkWell( + onTap: widget.disabled + ? null + : () => widget.chatStore.addEmote(widget.emotes[index]), + onLongPress: () { + HapticFeedback.lightImpact(); + + IRCMessage.showEmoteDetailsBottomSheet( + context, + emote: widget.emotes[index], + launchExternal: widget.chatStore.settings.launchUrlExternal, + ); + }, child: Padding( padding: const EdgeInsets.all(5.0), child: Center( child: FrostyCachedNetworkImage( - imageUrl: emotes[index].url, - height: emotes[index].height?.toDouble() ?? defaultEmoteSize, - width: emotes[index].width?.toDouble(), + imageUrl: widget.emotes[index].url, + height: + widget.emotes[index].height?.toDouble() ?? defaultEmoteSize, + width: widget.emotes[index].width?.toDouble(), + color: widget.disabled + ? const Color.fromRGBO(255, 255, 255, 0.5) + : null, + colorBlendMode: widget.disabled ? BlendMode.modulate : null, ), ), ), ), + itemCount: widget.emotes.length, ), - itemCount: emotes.length, ); } + + @override + bool get wantKeepAlive => true; } diff --git a/lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart b/lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart index 16b13c9f..f4fb7897 100644 --- a/lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart +++ b/lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart @@ -1,81 +1,91 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/constants.dart'; +import 'package:frosty/models/irc.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; 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/cached_image.dart'; +import 'package:frosty/widgets/frosty_cached_network_image.dart'; import 'package:provider/provider.dart'; class RecentEmotesPanel extends StatelessWidget { final ChatStore chatStore; - const RecentEmotesPanel({ - Key? key, - required this.chatStore, - }) : super(key: key); + const RecentEmotesPanel({super.key, required this.chatStore}); @override Widget build(BuildContext context) { - return CustomScrollView( - slivers: [ - if (chatStore.assetsStore.recentEmotes.isEmpty) - const SliverFillRemaining( - hasScrollBody: false, + return Observer( + builder: (context) { + if (chatStore.assetsStore.recentEmotes.isEmpty) { + return const Center( child: AlertMessage(message: 'No recent emotes'), - ) - else - SliverGrid( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: - MediaQuery.of(context).orientation == Orientation.portrait - ? 8 - : context.read().showVideo - ? 6 - : 16, - ), - delegate: SliverChildBuilderDelegate( - (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, - child: Tooltip( - message: emote.name, - preferBelow: false, - child: Padding( - padding: const EdgeInsets.all(10.0), - 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(), - ), - ), + return MediaQuery.removePadding( + context: context, + removeTop: true, + child: GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: context.isPortrait + ? 8 + : context.read().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(); + + 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(), ), ), - ); - }, - childCount: chatStore.assetsStore.recentEmotes.length, - ), + ), + ); + }, + itemCount: chatStore.assetsStore.recentEmotes.length, ), - ], + ); + }, ); } } diff --git a/lib/screens/channel/chat/stores/banned_user_tracker.dart b/lib/screens/channel/chat/stores/banned_user_tracker.dart new file mode 100644 index 00000000..83a929b2 --- /dev/null +++ b/lib/screens/channel/chat/stores/banned_user_tracker.dart @@ -0,0 +1,31 @@ +/// 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 = {}; + + /// 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; + } +} diff --git a/lib/screens/channel/chat/stores/chat_assets_store.dart b/lib/screens/channel/chat/stores/chat_assets_store.dart index dec1329b..6a2187ca 100644 --- a/lib/screens/channel/chat/stores/chat_assets_store.dart +++ b/lib/screens/channel/chat/stores/chat_assets_store.dart @@ -6,6 +6,9 @@ import 'package:frosty/apis/seventv_api.dart'; import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/models/badges.dart'; import 'package:frosty/models/emotes.dart'; +import 'package:frosty/models/shared_chat_session.dart'; +import 'package:frosty/models/user.dart'; +import 'package:frosty/stores/global_assets_store.dart'; import 'package:mobx/mobx.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -18,31 +21,61 @@ abstract class ChatAssetsStoreBase with Store { final BTTVApi bttvApi; final FFZApi ffzApi; final SevenTVApi sevenTVApi; + final GlobalAssetsStore globalAssetsStore; /// Contains any custom FFZ mod and vip badges for the channel. RoomFFZ? ffzRoomInfo; + String? sevenTvEmoteSetId; + + /// Tracks shared chat participant channels that we've already loaded assets for. + final Set _loadedSharedChannelIds = {}; + + /// Optional: Track 7TV set ids for shared chat participants (keyed by broadcaster id). + final sharedSevenTvSetIds = ObservableMap(); + + final channelIdToUserTwitch = ObservableMap(); + @computed List get bttvEmotes => - _emoteToObject.values.where((emote) => isBTTV(emote)).toList(); + emoteToObject.values.where((emote) => isBTTV(emote)).toList(); @computed List get ffzEmotes => - _emoteToObject.values.where((emote) => isFFZ(emote)).toList(); + emoteToObject.values.where((emote) => isFFZ(emote)).toList(); @computed List get sevenTVEmotes => - _emoteToObject.values.where((emote) => is7TV(emote)).toList(); - - /// The map of badges ids to their object representation. - final twitchBadgesToObject = ObservableMap(); + emoteToObject.values.where((emote) => is7TV(emote)).toList(); @readonly var _recentEmotes = ObservableList(); - /// The map of emote words to their image or GIF URL. May be used by anyone in the chat. + /// Channel-specific emotes only (not global). @readonly - var _emoteToObject = {}; + var _channelEmoteToObject = ObservableMap(); + + /// Channel-specific Twitch badges (overrides globals). + @readonly + var _channelTwitchBadges = {}; + + /// Combined emotes: global + channel. Channel emotes take precedence. + @computed + Map get emoteToObject { + final combined = Map.from(globalAssetsStore.globalEmoteMap); + combined.addAll(_channelEmoteToObject); + return combined; + } + + /// Combined Twitch badges: global + channel. Channel badges take precedence. + @computed + Map get twitchBadgesToObject { + final combined = Map.from( + globalAssetsStore.twitchGlobalBadges, + ); + combined.addAll(_channelTwitchBadges); + return combined; + } /// The emotes that are "owned" and may be used by the current user. @readonly @@ -54,17 +87,16 @@ abstract class ChatAssetsStoreBase with Store { // ignore: prefer_final_fields var _userEmoteSectionToEmotes = >{}; - /// The map of user IDs to their FFZ badges. - @readonly - var _userToFFZBadges = >{}; + /// The map of user IDs to their FFZ badges (from global store). + Map> get userToFFZBadges => + globalAssetsStore.ffzBadges; /// The map of user IDs to their 7TV badges. @readonly var _userTo7TVBadges = >{}; - /// The map of user IDs to their BTTV badge. - @readonly - var _userToBTTVBadges = {}; + /// The map of user IDs to their BTTV badge (from global store). + Map get userToBTTVBadges => globalAssetsStore.bttvBadges; /// Whether or not the emote menu is visible. @observable @@ -93,14 +125,16 @@ abstract class ChatAssetsStoreBase with Store { required this.bttvApi, required this.ffzApi, required this.sevenTVApi, + required this.globalAssetsStore, }); @action Future init() async { - // Retrieve the instance that will allow us to retrieve local search history. + // Load SharedPreferences for recent emotes. final prefs = await SharedPreferences.getInstance(); - _recentEmotes = prefs + _recentEmotes = + prefs .getStringList('recent_emotes') ?.map((emoteJson) => Emote.fromJson(jsonDecode(emoteJson))) .toList() @@ -109,89 +143,264 @@ abstract class ChatAssetsStoreBase with Store { _disposeReaction = autorun((_) { if (_recentEmotes.length > 48) _recentEmotes.removeLast(); - prefs.setStringList('recent_emotes', - _recentEmotes.map((emote) => jsonEncode(emote)).toList()); + prefs.setStringList( + 'recent_emotes', + _recentEmotes.map((emote) => jsonEncode(emote)).toList(), + ); }); } - /// Fetches global and channel assets (badges and emotes) and stores them in [_emoteToUrl] + /// Fetches channel-specific assets (badges and emotes). + /// Global assets are loaded via [globalAssetsStore] and combined automatically. @action Future assetsFuture({ required String channelId, required Map headers, required Function onEmoteError, required Function onBadgeError, - }) => - // Fetch the global and channel's assets (emotes & badges). - // Async awaits are placed in a list so they are performed in parallel. + bool showTwitchEmotes = true, + bool showTwitchBadges = true, + bool show7TVEmotes = true, + bool showBTTVEmotes = true, + bool showBTTVBadges = true, + bool showFFZEmotes = true, + bool showFFZBadges = true, + }) async { + // Fetch global (cached) and channel-specific assets in parallel + await Future.wait([ + // Global assets (cached across all tabs - only fetches once) + globalAssetsStore.ensureLoaded( + showTwitchEmotes: showTwitchEmotes, + showTwitchBadges: showTwitchBadges, + show7TVEmotes: show7TVEmotes, + showBTTVEmotes: showBTTVEmotes, + showBTTVBadges: showBTTVBadges, + showFFZEmotes: showFFZEmotes, + showFFZBadges: showFFZBadges, + ), + // Channel-specific emotes Future.wait([ - emotesFuture( - channelId: channelId, - headers: headers, - onError: onEmoteError, - ), - badgesFuture( - channelId: channelId, - headers: headers, - onError: onBadgeError, - ), - ]); + if (showTwitchEmotes) + twitchApi + .getEmotesChannel(id: channelId) + .then((emotes) { + _userEmoteSectionToEmotes.update( + 'Channel Emotes', + (existingEmoteSet) => [...existingEmoteSet, ...emotes], + ifAbsent: () => emotes.toList(), + ); + return emotes; + }) + .catchError(onEmoteError), + if (show7TVEmotes) + sevenTVApi + .getEmotesChannel(id: channelId) + .then((data) { + final (setId, emotes) = data; + sevenTvEmoteSetId = setId; + return emotes; + }) + .catchError(onEmoteError), + if (showBTTVEmotes) + bttvApi.getEmotesChannel(id: channelId).catchError(onEmoteError), + if (showFFZEmotes) + ffzApi + .getRoomInfo(id: channelId) + .then((ffzRoom) { + final (roomInfo, emotes) = ffzRoom; + ffzRoomInfo = roomInfo; + return emotes; + }) + .catchError(onEmoteError), + ]) + .then((assets) => assets.expand((list) => list)) + .then( + (emotes) => _channelEmoteToObject = { + for (final emote in emotes) emote.name: emote, + }.asObservable(), + ), + // Channel badges (Twitch only - FFZ/BTTV badges are global) + if (showTwitchBadges) + twitchApi + .getBadgesChannel(id: channelId) + .then((badges) => _channelTwitchBadges = badges) + .catchError(onBadgeError), + ]); + } + /// Fetch and merge assets for all participants in a shared chat session. + /// Safe to call multiple times; only new participant channels will be fetched. @action - Future emotesFuture({ + Future fetchSharedChatAssets({ required String channelId, required Map headers, - required Function onError, - }) => - // Parallel futures look ugly, but are worth the performance improvement. - // Each future has it's own catchError so that the entire future is not ended on an error. - // This will ensure that other futures complete even if one fails. - Future.wait([ - ffzApi.getEmotesGlobal().catchError(onError), - bttvApi.getEmotesGlobal().catchError(onError), - bttvApi.getEmotesChannel(id: channelId).catchError(onError), - twitchApi.getEmotesGlobal(headers: headers).catchError(onError), - twitchApi - .getEmotesChannel(id: channelId, headers: headers) - .catchError(onError), - sevenTVApi.getEmotesGlobal().catchError(onError), - sevenTVApi.getEmotesChannel(id: channelId).catchError(onError), - ffzApi.getRoomInfo(id: channelId).then((ffzRoom) { - ffzRoomInfo = ffzRoom.item1; - return ffzRoom.item2; - }).catchError(onError), - ]).then((assets) => assets.expand((list) => list)).then((emotes) => - _emoteToObject = {for (final emote in emotes) emote.name: emote}); + required Function onEmoteError, + required Function onBadgeError, + bool showTwitchEmotes = true, + bool showTwitchBadges = true, + bool show7TVEmotes = true, + bool showBTTVEmotes = true, + bool showFFZEmotes = true, + bool showFFZBadges = true, + bool force = false, + }) async { + if (force) { + _loadedSharedChannelIds.clear(); + sharedSevenTvSetIds.clear(); + } + final newParticipantIds = await populateSharedChatParticipants( + channelId: channelId, + onBadgeError: onBadgeError, + ); + + if (newParticipantIds.isEmpty) return; + + // Fetch emotes and badges for all new participants in parallel. + final futures = >[]; + + // Add emote futures for each participant. + futures.addAll( + newParticipantIds.map( + (id) => + _fetchEmotesForChannel( + id, + onEmoteError: onEmoteError, + showTwitchEmotes: showTwitchEmotes, + show7TVEmotes: show7TVEmotes, + showBTTVEmotes: showBTTVEmotes, + showFFZEmotes: showFFZEmotes, + ).then((emotes) { + for (final emote in emotes) { + _channelEmoteToObject[emote.name] = emote; + } + }), + ), + ); + + // Add badge futures for each participant. + if (showTwitchBadges) { + futures.addAll( + newParticipantIds.map( + (id) => _fetchBadgesForChannel(id, onBadgeError: onBadgeError), + ), + ); + } + + 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 - Future badgesFuture({ + Future> populateSharedChatParticipants({ required String channelId, - required Map headers, - required Function onError, - }) => - Future.wait([ - // Get global badges first, then channel badges to avoid badge conflicts. - // We want the channel badges to override the global badges. - twitchApi - .getBadgesGlobal(headers: headers) - .then((badges) => twitchBadgesToObject.addAll(badges)) - .then((_) => twitchApi - .getBadgesChannel(id: channelId, headers: headers) - .then((badges) => twitchBadgesToObject.addAll(badges)) - .catchError(onError)), - ffzApi - .getBadges() - .then((badges) => _userToFFZBadges = badges) - .catchError(onError), + required Function onBadgeError, + }) async { + SharedChatSession? sharedSession; + try { + sharedSession = await twitchApi.getSharedChatSession( + broadcasterId: channelId, + ); + } catch (e) { + onBadgeError(e); + return []; + } + + if (sharedSession == null) return []; + + final newParticipantIds = []; + for (final participant in sharedSession.participants) { + final id = participant.broadcasterId; + if (_loadedSharedChannelIds.add(id)) { + newParticipantIds.add(id); + } + } + + // Fetch user profiles concurrently. + if (newParticipantIds.isNotEmpty) { + await Future.wait( + newParticipantIds.map((id) async { + try { + final user = await twitchApi.getUser(id: id); + channelIdToUserTwitch[id] = user; + } catch (e) { + onBadgeError(e); + } + }), + ); + } + + return newParticipantIds; + } + + Future> _fetchEmotesForChannel( + String id, { + required Function onEmoteError, + bool showTwitchEmotes = true, + bool show7TVEmotes = true, + bool showBTTVEmotes = true, + bool showFFZEmotes = true, + }) async { + final futures = >>[]; + + if (showTwitchEmotes) { + futures.add(twitchApi.getEmotesChannel(id: id).catchError(onEmoteError)); + } + if (show7TVEmotes) { + futures.add( sevenTVApi - .getBadges() - .then((badges) => _userTo7TVBadges = badges) - .catchError(onError), - bttvApi - .getBadges() - .then((badges) => _userToBTTVBadges = badges) - .catchError(onError), - ]); + .getEmotesChannel(id: id) + .then((data) { + final (setId, emotes) = data; + sharedSevenTvSetIds[id] = setId; + return emotes; + }) + .catchError(onEmoteError), + ); + } + if (showBTTVEmotes) { + futures.add(bttvApi.getEmotesChannel(id: id).catchError(onEmoteError)); + } + if (showFFZEmotes) { + futures.add( + ffzApi + .getRoomInfo(id: id) + .then((ffzRoom) { + final (roomInfoIgnored, emotes) = ffzRoom; + return emotes; + }) + .catchError(onEmoteError), + ); + } + + final lists = await Future.wait(futures); + return lists.expand((e) => e).toList(); + } + + Future _fetchBadgesForChannel( + String id, { + required Function onBadgeError, + }) async { + try { + final badges = await twitchApi.getBadgesChannel(id: id); + _channelTwitchBadges = {..._channelTwitchBadges, ...badges}; + } catch (e) { + onBadgeError(e); + } + } @action Future userEmotesFuture({ @@ -199,20 +408,32 @@ abstract class ChatAssetsStoreBase with Store { required Map headers, required Function onError, }) async { - final userEmotes = await Future.wait(emoteSets.map((setId) => twitchApi - .getEmotesSets(setId: setId, headers: headers) - .catchError(onError))); + final userEmotes = await Future.wait( + emoteSets.map( + (setId) => twitchApi.getEmotesSets(setId: setId).catchError(onError), + ), + ); for (final emoteSet in userEmotes) { if (emoteSet.isNotEmpty) { if (emoteSet.first.type == EmoteType.twitchSub) { - final owner = await twitchApi.getUser( - id: emoteSet.first.ownerId, headers: headers); - _userEmoteSectionToEmotes.update( - owner.displayName, - (existingEmoteSet) => [...existingEmoteSet, ...emoteSet], - ifAbsent: () => emoteSet, - ); + final ownerId = emoteSet.first.ownerId; + + // Check for tuurbo emote sets (e.g., monkey set). + if (ownerId == 'twitch') { + _userEmoteSectionToEmotes.update( + 'Global Emotes', + (existingEmoteSet) => [...existingEmoteSet, ...emoteSet], + ifAbsent: () => emoteSet, + ); + } else { + final owner = await twitchApi.getUser(id: ownerId); + _userEmoteSectionToEmotes.update( + owner.displayName, + (existingEmoteSet) => [...existingEmoteSet, ...emoteSet], + ifAbsent: () => emoteSet, + ); + } } else if (emoteSet.first.type == EmoteType.twitchGlobal) { _userEmoteSectionToEmotes.update( 'Global Emotes', @@ -234,5 +455,19 @@ 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(); } diff --git a/lib/screens/channel/chat/stores/chat_assets_store.g.dart b/lib/screens/channel/chat/stores/chat_assets_store.g.dart index 322318f5..1c1e1b51 100644 --- a/lib/screens/channel/chat/stores/chat_assets_store.g.dart +++ b/lib/screens/channel/chat/stores/chat_assets_store.g.dart @@ -12,27 +12,46 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store { Computed>? _$bttvEmotesComputed; @override - List get bttvEmotes => - (_$bttvEmotesComputed ??= Computed>(() => super.bttvEmotes, - name: 'ChatAssetsStoreBase.bttvEmotes')) - .value; + List get bttvEmotes => (_$bttvEmotesComputed ??= Computed>( + () => super.bttvEmotes, + name: 'ChatAssetsStoreBase.bttvEmotes', + )).value; Computed>? _$ffzEmotesComputed; @override - List get ffzEmotes => - (_$ffzEmotesComputed ??= Computed>(() => super.ffzEmotes, - name: 'ChatAssetsStoreBase.ffzEmotes')) - .value; + List get ffzEmotes => (_$ffzEmotesComputed ??= Computed>( + () => super.ffzEmotes, + name: 'ChatAssetsStoreBase.ffzEmotes', + )).value; Computed>? _$sevenTVEmotesComputed; @override - List get sevenTVEmotes => (_$sevenTVEmotesComputed ??= - Computed>(() => super.sevenTVEmotes, - name: 'ChatAssetsStoreBase.sevenTVEmotes')) - .value; + List get sevenTVEmotes => + (_$sevenTVEmotesComputed ??= Computed>( + () => super.sevenTVEmotes, + name: 'ChatAssetsStoreBase.sevenTVEmotes', + )).value; + Computed>? _$emoteToObjectComputed; - late final _$_recentEmotesAtom = - Atom(name: 'ChatAssetsStoreBase._recentEmotes', context: context); + @override + Map get emoteToObject => + (_$emoteToObjectComputed ??= Computed>( + () => super.emoteToObject, + name: 'ChatAssetsStoreBase.emoteToObject', + )).value; + Computed>? _$twitchBadgesToObjectComputed; + + @override + Map get twitchBadgesToObject => + (_$twitchBadgesToObjectComputed ??= Computed>( + () => super.twitchBadgesToObject, + name: 'ChatAssetsStoreBase.twitchBadgesToObject', + )).value; + + late final _$_recentEmotesAtom = Atom( + name: 'ChatAssetsStoreBase._recentEmotes', + context: context, + ); ObservableList get recentEmotes { _$_recentEmotesAtom.reportRead(); @@ -49,26 +68,59 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store { }); } - late final _$_emoteToObjectAtom = - Atom(name: 'ChatAssetsStoreBase._emoteToObject', context: context); + late final _$_channelEmoteToObjectAtom = Atom( + name: 'ChatAssetsStoreBase._channelEmoteToObject', + context: context, + ); - Map get emoteToObject { - _$_emoteToObjectAtom.reportRead(); - return super._emoteToObject; + ObservableMap get channelEmoteToObject { + _$_channelEmoteToObjectAtom.reportRead(); + return super._channelEmoteToObject; } @override - Map get _emoteToObject => emoteToObject; + ObservableMap get _channelEmoteToObject => + channelEmoteToObject; @override - set _emoteToObject(Map value) { - _$_emoteToObjectAtom.reportWrite(value, super._emoteToObject, () { - super._emoteToObject = value; - }); + set _channelEmoteToObject(ObservableMap value) { + _$_channelEmoteToObjectAtom.reportWrite( + value, + super._channelEmoteToObject, + () { + super._channelEmoteToObject = value; + }, + ); } - late final _$_userEmoteToObjectAtom = - Atom(name: 'ChatAssetsStoreBase._userEmoteToObject', context: context); + late final _$_channelTwitchBadgesAtom = Atom( + name: 'ChatAssetsStoreBase._channelTwitchBadges', + context: context, + ); + + Map get channelTwitchBadges { + _$_channelTwitchBadgesAtom.reportRead(); + return super._channelTwitchBadges; + } + + @override + Map get _channelTwitchBadges => channelTwitchBadges; + + @override + set _channelTwitchBadges(Map value) { + _$_channelTwitchBadgesAtom.reportWrite( + value, + super._channelTwitchBadges, + () { + super._channelTwitchBadges = value; + }, + ); + } + + late final _$_userEmoteToObjectAtom = Atom( + name: 'ChatAssetsStoreBase._userEmoteToObject', + context: context, + ); Map get userEmoteToObject { _$_userEmoteToObjectAtom.reportRead(); @@ -86,7 +138,9 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store { } late final _$_userEmoteSectionToEmotesAtom = Atom( - name: 'ChatAssetsStoreBase._userEmoteSectionToEmotes', context: context); + name: 'ChatAssetsStoreBase._userEmoteSectionToEmotes', + context: context, + ); Map> get userEmoteSectionToEmotes { _$_userEmoteSectionToEmotesAtom.reportRead(); @@ -99,32 +153,19 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store { @override set _userEmoteSectionToEmotes(Map> value) { - _$_userEmoteSectionToEmotesAtom - .reportWrite(value, super._userEmoteSectionToEmotes, () { - super._userEmoteSectionToEmotes = value; - }); + _$_userEmoteSectionToEmotesAtom.reportWrite( + value, + super._userEmoteSectionToEmotes, + () { + super._userEmoteSectionToEmotes = value; + }, + ); } - late final _$_userToFFZBadgesAtom = - Atom(name: 'ChatAssetsStoreBase._userToFFZBadges', context: context); - - Map> get userToFFZBadges { - _$_userToFFZBadgesAtom.reportRead(); - return super._userToFFZBadges; - } - - @override - Map> get _userToFFZBadges => userToFFZBadges; - - @override - set _userToFFZBadges(Map> value) { - _$_userToFFZBadgesAtom.reportWrite(value, super._userToFFZBadges, () { - super._userToFFZBadges = value; - }); - } - - late final _$_userTo7TVBadgesAtom = - Atom(name: 'ChatAssetsStoreBase._userTo7TVBadges', context: context); + late final _$_userTo7TVBadgesAtom = Atom( + name: 'ChatAssetsStoreBase._userTo7TVBadges', + context: context, + ); Map> get userTo7TVBadges { _$_userTo7TVBadgesAtom.reportRead(); @@ -141,26 +182,10 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store { }); } - late final _$_userToBTTVBadgesAtom = - Atom(name: 'ChatAssetsStoreBase._userToBTTVBadges', context: context); - - Map get userToBTTVBadges { - _$_userToBTTVBadgesAtom.reportRead(); - return super._userToBTTVBadges; - } - - @override - Map get _userToBTTVBadges => userToBTTVBadges; - - @override - set _userToBTTVBadges(Map value) { - _$_userToBTTVBadgesAtom.reportWrite(value, super._userToBTTVBadges, () { - super._userToBTTVBadges = value; - }); - } - - late final _$showEmoteMenuAtom = - Atom(name: 'ChatAssetsStoreBase.showEmoteMenu', context: context); + late final _$showEmoteMenuAtom = Atom( + name: 'ChatAssetsStoreBase.showEmoteMenu', + context: context, + ); @override bool get showEmoteMenu { @@ -175,73 +200,150 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store { }); } - late final _$initAsyncAction = - AsyncAction('ChatAssetsStoreBase.init', context: context); + late final _$initAsyncAction = AsyncAction( + 'ChatAssetsStoreBase.init', + context: context, + ); @override Future init() { return _$initAsyncAction.run(() => super.init()); } - late final _$userEmotesFutureAsyncAction = - AsyncAction('ChatAssetsStoreBase.userEmotesFuture', context: context); + late final _$assetsFutureAsyncAction = AsyncAction( + 'ChatAssetsStoreBase.assetsFuture', + context: context, + ); @override - Future userEmotesFuture( - {required List emoteSets, - required Map headers, - required Function onError}) { - return _$userEmotesFutureAsyncAction.run(() => super.userEmotesFuture( - emoteSets: emoteSets, headers: headers, onError: onError)); + Future assetsFuture({ + required String channelId, + required Map headers, + required Function onEmoteError, + required Function onBadgeError, + bool showTwitchEmotes = true, + bool showTwitchBadges = true, + bool show7TVEmotes = true, + bool showBTTVEmotes = true, + bool showBTTVBadges = true, + bool showFFZEmotes = true, + bool showFFZBadges = true, + }) { + return _$assetsFutureAsyncAction.run( + () => super.assetsFuture( + channelId: channelId, + headers: headers, + onEmoteError: onEmoteError, + onBadgeError: onBadgeError, + showTwitchEmotes: showTwitchEmotes, + showTwitchBadges: showTwitchBadges, + show7TVEmotes: show7TVEmotes, + showBTTVEmotes: showBTTVEmotes, + showBTTVBadges: showBTTVBadges, + showFFZEmotes: showFFZEmotes, + showFFZBadges: showFFZBadges, + ), + ); } - late final _$ChatAssetsStoreBaseActionController = - ActionController(name: 'ChatAssetsStoreBase', context: context); + late final _$fetchSharedChatAssetsAsyncAction = AsyncAction( + 'ChatAssetsStoreBase.fetchSharedChatAssets', + context: context, + ); @override - Future assetsFuture( - {required String channelId, - required Map headers, - required Function onEmoteError, - required Function onBadgeError}) { + Future fetchSharedChatAssets({ + required String channelId, + required Map headers, + required Function onEmoteError, + required Function onBadgeError, + bool showTwitchEmotes = true, + bool showTwitchBadges = true, + bool show7TVEmotes = true, + bool showBTTVEmotes = true, + bool showFFZEmotes = true, + bool showFFZBadges = true, + bool force = false, + }) { + return _$fetchSharedChatAssetsAsyncAction.run( + () => super.fetchSharedChatAssets( + channelId: channelId, + headers: headers, + onEmoteError: onEmoteError, + onBadgeError: onBadgeError, + showTwitchEmotes: showTwitchEmotes, + showTwitchBadges: showTwitchBadges, + show7TVEmotes: show7TVEmotes, + showBTTVEmotes: showBTTVEmotes, + showFFZEmotes: showFFZEmotes, + showFFZBadges: showFFZBadges, + force: force, + ), + ); + } + + late final _$populateSharedChatParticipantsAsyncAction = AsyncAction( + 'ChatAssetsStoreBase.populateSharedChatParticipants', + context: context, + ); + + @override + Future> populateSharedChatParticipants({ + required String channelId, + required Function onBadgeError, + }) { + return _$populateSharedChatParticipantsAsyncAction.run( + () => super.populateSharedChatParticipants( + channelId: channelId, + onBadgeError: onBadgeError, + ), + ); + } + + late final _$userEmotesFutureAsyncAction = AsyncAction( + 'ChatAssetsStoreBase.userEmotesFuture', + context: context, + ); + + @override + Future userEmotesFuture({ + required List emoteSets, + required Map headers, + required Function onError, + }) { + return _$userEmotesFutureAsyncAction.run( + () => super.userEmotesFuture( + emoteSets: emoteSets, + headers: headers, + onError: onError, + ), + ); + } + + late final _$ChatAssetsStoreBaseActionController = ActionController( + name: 'ChatAssetsStoreBase', + context: context, + ); + + @override + void addChannelEmote(Emote emote) { final _$actionInfo = _$ChatAssetsStoreBaseActionController.startAction( - name: 'ChatAssetsStoreBase.assetsFuture'); + name: 'ChatAssetsStoreBase.addChannelEmote', + ); try { - return super.assetsFuture( - channelId: channelId, - headers: headers, - onEmoteError: onEmoteError, - onBadgeError: onBadgeError); + return super.addChannelEmote(emote); } finally { _$ChatAssetsStoreBaseActionController.endAction(_$actionInfo); } } @override - Future emotesFuture( - {required String channelId, - required Map headers, - required Function onError}) { + void removeChannelEmote(String emoteName) { final _$actionInfo = _$ChatAssetsStoreBaseActionController.startAction( - name: 'ChatAssetsStoreBase.emotesFuture'); + name: 'ChatAssetsStoreBase.removeChannelEmote', + ); try { - return super.emotesFuture( - channelId: channelId, headers: headers, onError: onError); - } finally { - _$ChatAssetsStoreBaseActionController.endAction(_$actionInfo); - } - } - - @override - Future badgesFuture( - {required String channelId, - required Map headers, - required Function onError}) { - final _$actionInfo = _$ChatAssetsStoreBaseActionController.startAction( - name: 'ChatAssetsStoreBase.badgesFuture'); - try { - return super.badgesFuture( - channelId: channelId, headers: headers, onError: onError); + return super.removeChannelEmote(emoteName); } finally { _$ChatAssetsStoreBaseActionController.endAction(_$actionInfo); } @@ -253,7 +355,9 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store { showEmoteMenu: ${showEmoteMenu}, bttvEmotes: ${bttvEmotes}, ffzEmotes: ${ffzEmotes}, -sevenTVEmotes: ${sevenTVEmotes} +sevenTVEmotes: ${sevenTVEmotes}, +emoteToObject: ${emoteToObject}, +twitchBadgesToObject: ${twitchBadgesToObject} '''; } } diff --git a/lib/screens/channel/chat/stores/chat_interaction_pause.dart b/lib/screens/channel/chat/stores/chat_interaction_pause.dart new file mode 100644 index 00000000..0d3456a9 --- /dev/null +++ b/lib/screens/channel/chat/stores/chat_interaction_pause.dart @@ -0,0 +1,54 @@ +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 = {}; + + /// 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 = {}; + + /// 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(); + } +} diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index 493aebd6..e7baa1c1 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -1,15 +1,24 @@ import 'dart:async'; +import 'dart:convert'; +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/material.dart'; -import 'package:frosty/constants.dart'; +import 'package:flutter/services.dart'; +import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/models/badges.dart'; import 'package:frosty/models/emotes.dart'; +import 'package:frosty/models/events.dart'; import 'package:frosty/models/irc.dart'; import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; +import 'package:frosty/screens/channel/chat/stores/banned_user_tracker.dart'; import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; +import 'package:frosty/screens/channel/chat/stores/chat_interaction_pause.dart'; +import 'package:frosty/screens/channel/chat/stores/shared_chat_bubble_state.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:frosty/utils.dart'; import 'package:mobx/mobx.dart'; -import 'package:wakelock/wakelock.dart'; +import 'package:web_socket_channel/io.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; part 'chat_store.g.dart'; @@ -21,9 +30,84 @@ abstract class ChatStoreBase with Store { /// The total maximum amount of messages in chat. static const _messageLimit = 5000; - /// The maximum ammount of messages to render when autoscroll is enabled. + /// The maximum amount of messages to render when autoscroll is enabled. static const _renderMessageLimit = 100; + /// IRC commands that should bypass chat delay. + /// These are confirmations/state updates rather than chat messages to sync. + static const _delayBypassCommands = { + 'USERSTATE', + 'GLOBALUSERSTATE', + 'ROOMSTATE', + 'NOTICE', // Rejection notices need immediate feedback + }; + + /// NOTICE msg-id values indicating message was rejected (from Twitch IRC docs). + /// https://dev.twitch.tv/docs/irc/msg-id/ + static const _rejectionMsgIds = { + // Rate limiting + 'msg_slowmode', + 'msg_ratelimit', + 'msg_duplicate', + // User restrictions + 'msg_banned', + 'msg_timedout', + 'msg_channel_blocked', + 'msg_suspended', + // Room mode restrictions + 'msg_emoteonly', + 'msg_subsonly', + 'msg_followersonly', + 'msg_followersonly_followed', + 'msg_followersonly_zero', + 'msg_r9k', + // Verification requirements + 'msg_verified_email', + 'msg_requires_verified_phone_number', + // Moderation + 'msg_rejected', + 'msg_rejected_mandatory', + 'msg_bad_characters', + }; + + /// Fallback height used to pad the message list for one frame, until the + /// real bottom bar reports its measured height via [setBottomBarHeight]. + static const _baseBottomBarHeight = 68.0; + + /// Server-initiated graceful reconnect notice. Twitch sends this untagged + /// before maintenance so clients can re-establish ahead of the close. + static const _serverReconnectNotice = ':tmi.twitch.tv RECONNECT'; + + /// Muted words pre-normalized (trimmed, lowercased, empties dropped) by a + /// reaction on the setting — avoids re-normalizing on every message. + var _normalizedMutedWords = []; + + /// Whether a single IRC [line] should bypass the chat delay and be handled + /// immediately. Displayable chat content (PRIVMSG/USERNOTICE and the + /// moderation CLEARCHAT/CLEARMSG) is delayed, so it stays in sync with the + /// delayed video. Everything else is handled at once: control/state messages + /// (USERSTATE/ROOMSTATE/GLOBALUSERSTATE), NOTICE (so a send rejection surfaces + /// immediately instead of one chat-delay later), the reconnect notice, and + /// any untagged server line (PING keepalives, CAP, JOIN, the welcome banner) + /// — delaying those would stall the send-ack, drop the PONG, or never start + /// the buffer drain. + bool _lineShouldBypassDelay(String line) { + if (line.isEmpty || line == _serverReconnectNotice) return true; + // Displayable chat content is always IRCv3-tagged; an untagged line is + // never delayed. + if (!line.startsWith('@')) return true; + final parts = line.split(' '); + return parts.length >= 3 && _delayBypassCommands.contains(parts[2]); + } + + Duration get _chatDelay => + Duration(milliseconds: (settings.effectiveChatDelay * 1000).round()); + + bool get _hasActiveChatDelay => + settings.showVideo && _chatDelay > Duration.zero; + + final TwitchApi twitchApi; + /// The amount of messages to free (remove) when the [_messageLimit] is reached. final _messagesToRemove = (_messageLimit * 0.2).toInt(); @@ -45,18 +129,39 @@ abstract class ChatStoreBase with Store { /// The channel's display name to show on widgets. final String displayName; + var _shouldDisconnect = false; + /// The Twitch IRC WebSocket channel. WebSocketChannel? _channel; /// The subscription that handles the WebSocket connection. StreamSubscription? _channelListener; + WebSocketChannel? _sevenTVChannel; + + /// The subscription that handles the 7TV WebSocket connection. + StreamSubscription? _sevenTVChannelListener; + + static const _maxRetries = 5; + + /// Ping interval for both the IRC and 7TV WebSocket sockets. Catches + /// zombie connections (silent network changes, OS suspends) without + /// waking the radio every 15s. Twitch's own recommended keepalive tops + /// out at 600s; community libs (e.g. dank-twitch-irc) sit around 30s. + static const _pingInterval = Duration(seconds: 60); + // The retry counter for exponential backoff. var _retries = 0; // The current time to wait between retries for exponential backoff. var _backoffTime = 0; + // Reference to the reconnect message for in-place updates. + IRCMessage? _reconnectMessage; + + // Timestamp when reconnection started, for calculating elapsed time. + DateTime? _reconnectStartTime; + /// The scroll controller that controls auto-scroll and resume-scroll behavior. final scrollController = ScrollController(); @@ -72,30 +177,84 @@ abstract class ChatStoreBase with Store { /// Requested message to be sent by the user. Will only be sent on receipt of a USERNOTICE command. IRCMessage? toSend; + /// Raw text of the most recent outgoing message, retained until the send is + /// confirmed. Lets the USERSTATE handler build the local echo even when the + /// message was sent before this session's first USERSTATE arrived (so + /// [toSend] couldn't be built up front). + String? _pendingSentMessageText; + /// The list of reaction disposer functions that will be used later when disposing. final reactions = []; /// The periodic timer used for batching chat message re-renders. - late final Timer _messageBufferTimer; + Timer? _messageBufferTimer; /// The list of chat messages to add once autoscroll is resumed. /// This is used as an optimization to prevent the list from being updated/shifted while the user is scrolling. - final _messageBuffer = []; + final messageBuffer = ObservableList(); + + /// Pending delayed IRC payloads waiting to be rendered. + final _pendingChatCallbacks = <_DelayedCallback>[]; + + /// Pending delayed 7TV payloads waiting to be rendered. + final _pendingSevenTVCallbacks = <_DelayedCallback>[]; + + /// The set of message IDs that have been revealed by the user (for deleted messages). + final revealedMessageIds = ObservableSet(); + + /// User-driven spotlight/hide selections for shared chat participant + /// channels. See [SharedChatBubbleState]. + final sharedChatBubbles = SharedChatBubbleState(); + + @action + void revealMessage(String id) { + revealedMessageIds.add(id); + } + + /// Whether a message from [sourceChannelId] should render faded. + /// + /// [overrideCurrentChannelId] is set only when rendering the manual + /// "merged mode" view (multiple manually-added tabs interleaved), which + /// always fades non-active-tab messages, gated by [focusCurrentChannel]. + /// Otherwise this is the normal single-connection view, where native + /// Twitch shared chat defaults to unfaded and only fades once the user + /// spotlights a participant channel via a chat bubble — an explicit + /// action that isn't gated by that setting. + bool shouldFadeMessage({ + required String? sourceChannelId, + required String? overrideCurrentChannelId, + }) { + if (sourceChannelId == null) return false; + + final currentChannelId = overrideCurrentChannelId ?? channelId; + if (sourceChannelId == currentChannelId) return false; + + final isMerged = overrideCurrentChannelId != null; + if (isMerged) return settings.focusCurrentChannel; + + return sharedChatBubbles.focusedChannelIds.isNotEmpty && + !sharedChatBubbles.isFocused(sourceChannelId); + } /// Timer used for dismissing the notification. Timer? _notificationTimer; + /// Timer used for resetting the sending state if no acknowledgment is received. + Timer? _sendingTimeoutTimer; + + /// Timer used for updating the chat delay countdown message. + Timer? _chatDelayCountdownTimer; + + /// Reference to the current countdown message for direct updates (avoids O(n) search). + IRCMessage? _countdownMessage; + + /// Tracks whether the initial chat delay sync has completed for the current session. + /// Prevents the countdown from re-appearing when latency updates arrive after initial sync. + bool _chatDelaySyncCompleted = false; + /// The current timer for the sleep timer if active. Timer? sleepTimer; - /// The amount of hours the sleep timer is set to. - @observable - var sleepHours = 0; - - /// The amount of minutes the sleep timer is set to. - @observable - var sleepMinutes = 0; - /// The time remaining for the sleep timer. @observable var timeRemaining = const Duration(); @@ -112,21 +271,54 @@ abstract class ChatStoreBase with Store { @computed List get renderMessages { // If autoscroll is disabled, render ALL messages in chat. - // The second condition is to prevent an out of index error with sublist. - if (!_autoScroll || _messages.length < _renderMessageLimit) { - return _messages; - } + // The second condition is to prevent an out-of-range index error with sublist. + final base = (!_autoScroll || _messages.length < _renderMessageLimit) + ? _messages + : _messages.sublist(_messages.length - _renderMessageLimit); - // When autoscroll is enabled, only show the first [_renderMessageLimit] messages. - // This will improve performance by only rendering a limited amount of messages - // instead of the entire history at all times. - return _messages.sublist(_messages.length - _renderMessageLimit); + // Filter out messages from shared chat participant channels the user + // has hidden. Short-circuit when nothing is hidden (the common case). + if (sharedChatBubbles.hiddenChannelIds.isEmpty) return base; + return base.where((message) { + final sourceChannelId = message.sourceChannelId; + return sourceChannelId == null || + !sharedChatBubbles.hiddenChannelIds.contains(sourceChannelId); + }).toList(); } /// If the chat should automatically scroll/jump to the latest message. @readonly var _autoScroll = true; + /// Tracks users banned/timed-out via CLEARCHAT so the moderation menu only + /// offers an unban action when it applies. + final _bannedUserTracker = BannedUserTracker(); + + /// Whether [userId] is currently banned or within an unexpired timeout + /// (as observed from CLEARCHAT in this session). + bool isUserBannedOrTimedOut(String userId) => + _bannedUserTracker.isBannedOrTimedOut(userId, DateTime.now()); + + /// Forget a user's ban state after a successful unban so the action hides. + void clearUserBanState(String userId) => _bannedUserTracker.clear(userId); + + /// Freezes the rendered list while the user is touching it, so a long-press + /// target can't scroll out from under the finger and get its gesture + /// recognizer disposed mid-press. Plain field (not observable): it only gates + /// the periodic [addMessages] flush, which reads it each 200ms tick. + final _interactionPause = ChatInteractionPause(); + + /// Hooks for the message list to report touch start/move/end (see [Chat]). + void onMessageListPointerDown(int pointer, Offset position) => + _interactionPause.pointerDown(pointer, position); + void onMessageListPointerMove(int pointer, Offset position) => + _interactionPause.pointerMove(pointer, position); + void onMessageListPointerUp(int pointer) => + _interactionPause.pointerUp(pointer); + + @readonly + var _inputText = ''; + @readonly var _showSendButton = false; @@ -136,6 +328,23 @@ abstract class ChatStoreBase with Store { @readonly var _showMentionAutocomplete = false; + /// Whether the chat is currently in shared chat mode (based on source-room-id tag presence). + @readonly + var _isInSharedChatMode = false; + + /// Whether we're waiting for server acknowledgment of a sent message. + @readonly + var _isWaitingForAck = false; + + /// Whether the chat WebSocket is currently connected. + @readonly + var _isConnected = false; + + /// Whether we've successfully connected at least once (to distinguish + /// "connecting" from "disconnected" in the UI). + @readonly + var _hasConnected = false; + /// The logged-in user's appearance in chat. @readonly var _userState = const USERSTATE(); @@ -143,7 +352,62 @@ abstract class ChatStoreBase with Store { @observable var expandChat = false; + @observable + IRCMessage? replyingToMessage; + + /// Emotes matching the current autocomplete search term. + @computed + List get matchingEmotes { + if (!_showEmoteAutocomplete) return const []; + final searchTerm = _inputText.split(' ').last.toLowerCase(); + if (searchTerm.isEmpty) return const []; + + return [ + ...assetsStore.userEmoteToObject.values, + ...assetsStore.bttvEmotes, + ...assetsStore.ffzEmotes, + ...assetsStore.sevenTVEmotes, + ].where((emote) => emote.name.toLowerCase().contains(searchTerm)).toList(); + } + + /// Chatters matching the current mention autocomplete search term. + @computed + List get matchingChatters { + if (!_showMentionAutocomplete) return const []; + final searchTerm = _inputText + .split(' ') + .last + .replaceFirst('@', '') + .toLowerCase(); + return chatDetailsStore.chatUsers + .where((chatter) => chatter.contains(searchTerm)) + .toList(); + } + + /// Measured height of the rendered bottom bar (input plus any reply or + /// autocomplete overlays). The bar reports this via [setBottomBarHeight] so + /// the message list's bottom padding tracks the bar's *true* height — + /// including multi-line input growth and OS text scaling, which fixed + /// per-overlay constants silently undercount. + final _measuredBottomBarHeight = Observable(null); + + /// Current bottom bar height, used to dynamically pad the message list so the + /// bar never overlaps messages. Falls back to the single-line base height + /// until the real bar reports its first measurement. + @computed + double get bottomBarHeight => + _measuredBottomBarHeight.value ?? _baseBottomBarHeight; + + /// Reports the bar's real rendered height. Sub-pixel changes are ignored to + /// avoid needless message-list relayouts. + void setBottomBarHeight(double height) { + final current = _measuredBottomBarHeight.value; + if (current != null && (current - height).abs() < 0.5) return; + runInAction(() => _measuredBottomBarHeight.value = height); + } + ChatStoreBase({ + required this.twitchApi, required this.auth, required this.chatDetailsStore, required this.assetsStore, @@ -152,34 +416,94 @@ abstract class ChatStoreBase with Store { required this.channelId, required this.displayName, }) { - // Enable wakelock to prevent the chat from sleeping. - if (settings.chatOnlyPreventSleep) Wakelock.enable(); - // Create a reaction that will reconnect to chat when logging in or out. // Closing the channel will trigger a reconnect with the new credentials. + reactions.add( + reaction((_) => auth.isLoggedIn, (_) => _channel?.sink.close(1000)), + ); + + // Normalize muted words once per settings change instead of per message + // — the filter runs in the IRC hot path (50+ msgs/s in busy channels). + reactions.add( + reaction((_) => settings.mutedWords.toList(), (List words) { + _normalizedMutedWords = words + .map((word) => word.trim().toLowerCase()) + .where((word) => word.isNotEmpty) + .toList(); + }, fireImmediately: true), + ); + reactions.add( reaction( - (_) => auth.isLoggedIn, - (_) => _channel?.sink.close(1001), + (_) => [ + settings.showTwitchEmotes, + settings.showTwitchBadges, + settings.show7TVEmotes, + settings.showBTTVEmotes, + settings.showBTTVBadges, + settings.showFFZEmotes, + settings.showFFZBadges, + ], + (_) { + if (!settings.show7TVEmotes) { + _sevenTVChannel?.sink.close(1000); + } + + getAssets().then((_) { + if (settings.show7TVEmotes) { + final emoteSetId = assetsStore.sevenTvEmoteSetId; + if (emoteSetId != null) { + listenToSevenTVEmoteSet(emoteSetId: emoteSetId); + } + } + }); + }, ), ); - // Create a timer that will add messages from the buffer every 200 milliseconds. - _messageBufferTimer = Timer.periodic( - const Duration(milliseconds: 200), (timer) => addMessages()); + // Start chat delay countdown when toggling video on, cancel when off + reactions.add( + reaction((_) => settings.showVideo, (showVideo) { + if (showVideo && settings.effectiveChatDelay > 0) { + _startChatDelayCountdown(); + } else if (!showVideo) { + _flushPendingDelayedCallbacks(); + _cancelChatDelayCountdown(); + _chatDelaySyncCompleted = false; + } + }), + ); + + // Start chat delay countdown when chatDelay is set (for auto sync mode) + // Cancel countdown if delay becomes 0 + reactions.add( + reaction((_) => settings.effectiveChatDelay, (chatDelay) { + if (chatDelay == 0) { + _flushPendingDelayedCallbacks(); + _cancelChatDelayCountdown(); + _chatDelaySyncCompleted = false; + } else if (settings.autoSyncChatDelay && + settings.showVideo && + chatDelay > 0 && + !_chatDelaySyncCompleted) { + _startChatDelayCountdown(); + } + }), + ); assetsStore.init(); - _messageBuffer - .add(IRCMessage.createNotice(message: 'Connecting to chat...')); + _messages.add(IRCMessage.createNotice(message: 'Connecting to chat...')); - if (settings.chatDelay > 0) { - _messageBuffer.add(IRCMessage.createNotice( - message: - 'Waiting ${settings.chatDelay.toInt()} ${settings.chatDelay == 1.0 ? 'second' : 'seconds'} due to message delay setting...')); + if (settings.showRecentMessages) { + getRecentMessage().then((_) => connectToChat()); + } else { + connectToChat(); } - connectToChat(); + // Refresh which channels the user moderates on entry so mod actions reflect + // status granted/revoked since login. Fire-and-forget, like blocked users. + if (auth.isLoggedIn) auth.user.refreshModeratedChannels(); // Tell the scrollController to determine when auto-scroll should be enabled or disabled. scrollController.addListener(() { @@ -204,18 +528,86 @@ abstract class ChatStoreBase with Store { // Add a listener to the textfield that will show/hide the autocomplete bar if focused. // Will also rebuild the autocomplete bar when typing, refreshing the results as the user types. - textController.addListener(() => _showEmoteAutocomplete = - !_showMentionAutocomplete && - textFieldFocusNode.hasFocus && - textController.text.split(' ').last.isNotEmpty); - textController.addListener(() { + _inputText = textController.text; + + _showEmoteAutocomplete = + !_showMentionAutocomplete && + textFieldFocusNode.hasFocus && + textController.text.split(' ').last.isNotEmpty; + _showSendButton = textController.text.isNotEmpty; - _showMentionAutocomplete = textFieldFocusNode.hasFocus && + _showMentionAutocomplete = + textFieldFocusNode.hasFocus && textController.text.split(' ').last.startsWith('@'); }); } + void _enqueueDelayedCallback( + List<_DelayedCallback> queue, + void Function() callback, + ) { + queue.add( + _DelayedCallback( + releaseAt: DateTime.now().add(_chatDelay), + callback: callback, + ), + ); + } + + void _flushDueDelayedCallbacks() { + _flushDueDelayedCallbacksForQueue(_pendingChatCallbacks); + _flushDueDelayedCallbacksForQueue(_pendingSevenTVCallbacks); + } + + void _flushDueDelayedCallbacksForQueue(List<_DelayedCallback> queue) { + if (queue.isEmpty) return; + + final now = DateTime.now(); + final readyCallbacks = <_DelayedCallback>[]; + + queue.removeWhere((delayedCallback) { + final isReady = !delayedCallback.releaseAt.isAfter(now); + if (isReady) { + readyCallbacks.add(delayedCallback); + } + return isReady; + }); + + for (final delayedCallback in readyCallbacks) { + delayedCallback.callback(); + } + } + + void _flushPendingDelayedCallbacks() { + _flushPendingDelayedCallbacksForQueue(_pendingChatCallbacks); + _flushPendingDelayedCallbacksForQueue(_pendingSevenTVCallbacks); + } + + void _flushPendingDelayedCallbacksForQueue(List<_DelayedCallback> queue) { + if (queue.isEmpty) return; + + final pendingCallbacks = List<_DelayedCallback>.from(queue); + queue.clear(); + + for (final delayedCallback in pendingCallbacks) { + delayedCallback.callback(); + } + } + + void _clearPendingDelayedCallbacks({ + bool clearChat = true, + bool clearSevenTV = true, + }) { + if (clearChat) { + _pendingChatCallbacks.clear(); + } + + if (clearSevenTV) { + _pendingSevenTVCallbacks.clear(); + } + } + /// Handle and process the provided string-representation of the IRC data. /// /// If a message, parses the IRC data into an [IRCMessage] and handles it based on the [Command]. @@ -226,9 +618,29 @@ abstract class ChatStoreBase with Store { // To account for this, split by CRLF, then loop and process each message. for (final message in data.trimRight().split('\r\n')) { // debugPrint('$message\n'); + if (message == _serverReconnectNotice) { + // Twitch is about to close the socket for maintenance — close it + // ourselves so the existing onDone path reconnects with no backoff + // (first retry is immediate) instead of waiting for the server to + // drop us mid-message. + _channel?.sink.close(1000); + continue; + } + if (message.startsWith('@')) { - final parsedIRCMessage = - IRCMessage.fromString(message, userLogin: auth.user.details?.login); + // Parse defensively: a single malformed message must never abort the + // loop, or one bad frame would drop every other message batched with + // it (e.g. an entire recent-messages backlog). Skip and keep going. + final IRCMessage parsedIRCMessage; + try { + parsedIRCMessage = IRCMessage.fromString( + message, + userLogin: auth.user.details?.login, + ); + } catch (e) { + debugPrint('Failed to parse IRC message: $e'); + continue; + } if (parsedIRCMessage.user != null) { chatDetailsStore.chatUsers.add(parsedIRCMessage.user!); @@ -238,43 +650,153 @@ abstract class ChatStoreBase with Store { if (!_userState.mod && channelName != auth.user.details?.login && auth.user.blockedUsers - .where((blockedUser) => - blockedUser.userLogin == parsedIRCMessage.user) + .where( + (blockedUser) => + blockedUser.userLogin == parsedIRCMessage.user, + ) .isNotEmpty) { continue; } + // Filter messages containing any muted words. Must use `continue` + // (not `return`) — a single IRC frame can batch many messages, and + // returning would drop the rest of the frame with the matched one. + if (parsedIRCMessage.message != null && + _normalizedMutedWords.isNotEmpty) { + final msg = parsedIRCMessage.message!.toLowerCase(); + final words = settings.matchWholeWord ? msg.split(' ') : null; + final muted = _normalizedMutedWords.any( + (word) => words != null ? words.contains(word) : msg.contains(word), + ); + if (muted) continue; + } + switch (parsedIRCMessage.command) { case Command.privateMessage: case Command.notice: case Command.userNotice: - _messageBuffer.add(parsedIRCMessage); + // Check if this is a rejection notice for a pending message + if (parsedIRCMessage.command == Command.notice && + _isWaitingForAck) { + final msgId = parsedIRCMessage.tags['msg-id']; + if (msgId != null && _rejectionMsgIds.contains(msgId)) { + // Message was rejected - clear pending but preserve text field for retry + _sendingTimeoutTimer?.cancel(); + _isWaitingForAck = false; + toSend = null; + // Drop the retained text so a later USERSTATE can't render the + // rejected message as a deferred echo. + _pendingSentMessageText = null; + + // Show notification with Twitch's rejection message + if (parsedIRCMessage.message != null) { + HapticFeedback.heavyImpact(); + updateNotification(parsedIRCMessage.message!); + } + } + } + + // Update shared chat mode based on source-room-id tag presence. + // Only PRIVMSG reliably carries this tag for the session's + // duration — NOTICE/USERNOTICE (subs, raids, announcements) + // don't always, so gating on those too would flap this flag off + // and on within a single live session (now visibly, since the + // shared chat bubble bar reads it — previously this only risked + // a redundant asset refetch, guarded below). + if (parsedIRCMessage.command == Command.privateMessage) { + final wasShared = _isInSharedChatMode; + _isInSharedChatMode = parsedIRCMessage.tags.containsKey( + 'source-room-id', + ); + // On transition into shared chat mode, fetch assets for participants. + if (!wasShared && _isInSharedChatMode) { + assetsStore.fetchSharedChatAssets( + channelId: channelId, + headers: auth.headersTwitch, + onEmoteError: (error) { + debugPrint(error.toString()); + return []; + }, + onBadgeError: (error) { + debugPrint(error.toString()); + return {}; + }, + showTwitchEmotes: settings.showTwitchEmotes, + showTwitchBadges: settings.showTwitchBadges, + show7TVEmotes: settings.show7TVEmotes, + showBTTVEmotes: settings.showBTTVEmotes, + showFFZEmotes: settings.showFFZEmotes, + showFFZBadges: settings.showFFZBadges, + ); + } else if (wasShared && + !_isInSharedChatMode && + assetsStore.hasLoadedSharedChatAssets) { + // On leaving shared chat, drop the participants' merged emotes and + // badges and restore just this channel's assets — otherwise the + // other streamer's emote set and badges linger (#522). Guarded on + // having actually loaded shared assets so a flapping tag can't + // trigger redundant refetches. + assetsStore.resetSharedChatAssets(); + getAssets(); + } + } + messageBuffer.add(parsedIRCMessage); break; case Command.clearChat: + // Track the banned/timed-out user so the chat can offer an unban + // action only when applicable. target-user-id is absent for a + // whole-chat clear. + final clearedUserId = parsedIRCMessage.tags['target-user-id']; + if (clearedUserId != null) { + final banDuration = parsedIRCMessage.tags['ban-duration']; + final seconds = banDuration != null + ? int.tryParse(banDuration) + : null; + if (seconds != null) { + _bannedUserTracker.recordTimeout( + clearedUserId, + Duration(seconds: seconds), + DateTime.now(), + ); + } else { + _bannedUserTracker.recordBan(clearedUserId); + } + } IRCMessage.clearChat( messages: _messages, - bufferedMessages: _messageBuffer, + bufferedMessages: messageBuffer, ircMessage: parsedIRCMessage, ); + // clearChat marks messages in place, which doesn't notify the + // observable list; reassign so renderMessages recomputes and the + // ban/timeout strike-through shows without waiting for the next + // message. + _messages = _messages.toList().asObservable(); break; case Command.clearMessage: IRCMessage.clearMessage( messages: _messages, - bufferedMessages: _messageBuffer, + bufferedMessages: messageBuffer, ircMessage: parsedIRCMessage, ); + // Same as clearChat: force a re-render so the deleted message + // updates immediately rather than on the next incoming message. + _messages = _messages.toList().asObservable(); break; case Command.roomState: - chatDetailsStore.roomState = - chatDetailsStore.roomState.fromIRCMessage(parsedIRCMessage); + chatDetailsStore.roomState = chatDetailsStore.roomState + .fromIRCMessage(parsedIRCMessage); continue; case Command.userState: _userState = _userState.fromIRCMessage(parsedIRCMessage); - if (toSend != null) { - textController.clear(); - _messageBuffer.add(toSend!); - toSend = null; + // A USERSTATE following our PRIVMSG confirms the send. Finalize + // regardless of whether a local echo was pre-built — gating this on + // `toSend` left the player stuck "sending" (and firing a false + // "may not have been sent") whenever the message went out before + // this session's first USERSTATE populated `_userState`. + if (_isWaitingForAck) { + _finalizeSend(parsedIRCMessage.tags['id']); } break; case Command.globalUserState: @@ -297,9 +819,9 @@ abstract class ChatStoreBase with Store { if (!_autoScroll) { // While autoscroll is disabled, occasionally move messages from the buffer to the messages to prevent a memory leak. - if (_messageBuffer.length >= _messagesToRemove) { - _messages.addAll(_messageBuffer); - _messageBuffer.clear(); + if (messageBuffer.length >= _messagesToRemove) { + _messages.addAll(messageBuffer); + messageBuffer.clear(); } } @@ -311,33 +833,109 @@ abstract class ChatStoreBase with Store { _channel?.sink.add('PONG :tmi.twitch.tv'); return; } else if (message.contains('Welcome, GLHF!')) { - _messageBuffer.add(IRCMessage.createNotice( + messageBuffer.add( + IRCMessage.createNotice( message: - "Connected to $displayName${regexEnglish.hasMatch(displayName) ? '' : ' ($channelName)'}'s chat!")); + "Welcome to ${getReadableName(displayName, channelName)}'s chat!", + ), + ); - getAssets(); + // Activate the message buffer. + // Cancel any existing timer before creating a new one to prevent duplicates on reconnect. + _messageBufferTimer?.cancel(); + // Create a timer that will add messages from the buffer every 200 milliseconds. + _messageBufferTimer = Timer.periodic( + const Duration(milliseconds: 200), + (timer) => addMessages(), + ); + + // Set up 7TV real-time listener (assets already fetched in connectToChat) + if (settings.show7TVEmotes) { + final emoteSetId = assetsStore.sevenTvEmoteSetId; + if (emoteSetId != null && !_shouldDisconnect) { + listenToSevenTVEmoteSet(emoteSetId: emoteSetId); + } + } + + // Transform reconnect message to summary on successful connection + if (_reconnectMessage != null && _reconnectStartTime != null) { + final elapsed = DateTime.now() + .difference(_reconnectStartTime!) + .inSeconds; + final attempts = _retries; + final index = _messages.indexOf(_reconnectMessage!); + if (index != -1) { + _messages[index] = IRCMessage.createNotice( + message: + 'Reconnected ($attempts ${attempts == 1 ? 'attempt' : 'attempts'}, ${elapsed}s)', + ); + } + } + _reconnectMessage = null; + _reconnectStartTime = null; // Reset exponential backoff if successfully connected. _retries = 0; _backoffTime = 0; + + // Mark connection as established for UI + _isConnected = true; + _hasConnected = true; } } } // Fetch the assets used in chat including badges and emotes. @action - Future getAssets() async => assetsStore.assetsFuture( + Future getAssets() async { + // Prepare common error handlers to avoid repetition. + List onEmoteError(dynamic error) { + debugPrint(error.toString()); + return []; + } + + // Returns a Map, not a List: this is attached via `.catchError` to the + // `getBadgesChannel` future (a Future>), so the + // handler must return that type or Dart throws at runtime. + Map onBadgeError(dynamic error) { + debugPrint(error.toString()); + return {}; + } + + final baseAssets = assetsStore.assetsFuture( + channelId: channelId, + headers: auth.headersTwitch, + onEmoteError: onEmoteError, + onBadgeError: onBadgeError, + showTwitchEmotes: settings.showTwitchEmotes, + showTwitchBadges: settings.showTwitchBadges, + show7TVEmotes: settings.show7TVEmotes, + showBTTVEmotes: settings.showBTTVEmotes, + showBTTVBadges: settings.showBTTVBadges, + showFFZEmotes: settings.showFFZEmotes, + showFFZBadges: settings.showFFZBadges, + ); + + // If shared chat mode is active, also refresh participant assets in parallel. + if (_isInSharedChatMode) { + final sharedAssets = assetsStore.fetchSharedChatAssets( channelId: channelId, headers: auth.headersTwitch, - onEmoteError: (error) { - debugPrint(error.toString()); - return []; - }, - onBadgeError: (error) { - debugPrint(error.toString()); - return []; - }, + onEmoteError: onEmoteError, + onBadgeError: onBadgeError, + showTwitchEmotes: settings.showTwitchEmotes, + showTwitchBadges: settings.showTwitchBadges, + show7TVEmotes: settings.show7TVEmotes, + showBTTVEmotes: settings.showBTTVEmotes, + showFFZEmotes: settings.showFFZEmotes, + showFFZBadges: settings.showFFZBadges, + force: true, ); + await Future.wait([baseAssets, sharedAssets]); + } else { + await baseAssets; + } + } /// Re-enables [_autoScroll] and jumps to the latest message. @action @@ -354,37 +952,280 @@ abstract class ChatStoreBase with Store { } @action - void connectToChat() { - _channel?.sink.close(1001); - _channel = - WebSocketChannel.connect(Uri.parse('wss://irc-ws.chat.twitch.tv:443')); + void listenToSevenTVEmoteSet({required String emoteSetId}) { + final subscribePayload = SevenTVEvent( + op: 35, + d: SevenTVEventData( + type: 'emote_set.update', + condition: {'object_id': emoteSetId}, + ), + ); + + _sevenTVChannel?.sink.close(1000); + _clearPendingDelayedCallbacks(clearChat: false); + _sevenTVChannel = IOWebSocketChannel.connect( + Uri.parse('wss://events.7tv.io/v3'), + pingInterval: _pingInterval, + ); + + void listener(dynamic data) { + // debugPrint(data); + final decoded = jsonDecode(data); + + final event = SevenTVEvent.fromJson(decoded); + + final body = event.d.body; + if (event.d.type != 'emote_set.update' || body == null) return; + + if (body.pushed != null) { + final pushedEmote = body.pushed?.first.value; + + if (pushedEmote == null) return; + + final emote = Emote.from7TV(pushedEmote, EmoteType.sevenTVChannel); + + assetsStore.addChannelEmote(emote); + + messageBuffer.add( + IRCMessage.createNotice( + message: + '${getReadableName(body.actor.displayName, body.actor.username)} added 7TV emote "${emote.name}" to chat', + ), + ); + } else if (body.pulled != null) { + final pulledEmote = body.pulled?.first.oldValue; + + if (pulledEmote == null) return; + + assetsStore.removeChannelEmote(pulledEmote.name); + + messageBuffer.add( + IRCMessage.createNotice( + message: + '${getReadableName(body.actor.displayName, body.actor.username)} removed 7TV emote "${pulledEmote.name}" from chat', + ), + ); + } + } + + // Cancel previous subscription if exists + _sevenTVChannelListener?.cancel(); + _sevenTVChannelListener = _sevenTVChannel?.stream.listen( + (data) { + if (!_hasActiveChatDelay) { + listener(data); + } else { + _enqueueDelayedCallback( + _pendingSevenTVCallbacks, + () => listener(data), + ); + } + }, + onError: (error) { + debugPrint('7TV events error: ${error.toString()}'); + FirebaseCrashlytics.instance.recordError( + error, + StackTrace.current, + reason: '7TV WebSocket error, channel=$channelName', + ); + }, + onDone: () { + _clearPendingDelayedCallbacks(clearChat: false); + debugPrint('7TV events done'); + }, + ); + + _sevenTVChannel?.sink.add(jsonEncode(subscribePayload)); + } + + @action + Future connectToChat({bool isReconnect = false}) async { + FirebaseCrashlytics.instance.setCustomKey('channel', channelName); + + // Fetch assets first so they're available for all messages + getAssets().catchError( + (e) => debugPrint('Failed to fetch chat assets: $e'), + ); + + // Cancel existing listener to prevent duplicate message processing + _channelListener?.cancel(); + _clearPendingDelayedCallbacks(clearSevenTV: false); + + _channel?.sink.close(1000); + _channel = IOWebSocketChannel.connect( + Uri.parse('wss://irc-ws.chat.twitch.tv:443'), + pingInterval: _pingInterval, + ); + + // Only show chat delay countdown on initial connection or video toggle, not on reconnects + if (!isReconnect && _hasActiveChatDelay) { + _startChatDelayCountdown(); + } // Listen for new messages and forward them to the handler. _channelListener = _channel?.stream.listen( - (data) => Future.delayed(Duration(seconds: settings.chatDelay.toInt()), - () => _handleIRCData(data.toString())), - onError: (error) => debugPrint('Chat error: ${error.toString()}'), - onDone: () async { - if (_channel == null) return; + (data) { + final dataStr = data.toString(); - if (_backoffTime > 0) { - // Add notice that chat was disconnected and then wait the backoff time before reconnecting. - final notice = - 'Disconnected from chat, waiting $_backoffTime ${_backoffTime == 1 ? 'second' : 'seconds'} before reconnecting...'; - _messageBuffer.add(IRCMessage.createNotice(message: notice)); + // No active delay: process the whole frame immediately. + if (!_hasActiveChatDelay) { + _handleIRCData(dataStr); + return; } - await Future.delayed(Duration(seconds: _backoffTime)); + // Dispatch each line individually so a bypass-eligible control message + // isn't dragged behind the delay by chat lines batched in the same + // frame — and, conversely, so a delayed chat line can't hold up a + // PING/USERSTATE that shares the frame. + for (final line in dataStr.split('\r\n')) { + final trimmed = line.trimRight(); + if (trimmed.isEmpty) continue; + if (_lineShouldBypassDelay(trimmed)) { + _handleIRCData(trimmed); + } else { + _enqueueDelayedCallback( + _pendingChatCallbacks, + () => _handleIRCData(trimmed), + ); + } + } + }, + onError: (error) { + debugPrint('Chat error: ${error.toString()}'); + FirebaseCrashlytics.instance.recordError( + error, + StackTrace.current, + reason: 'IRC WebSocket error, channel=$channelName', + ); + }, + onDone: () async { + _clearPendingDelayedCallbacks(clearSevenTV: false); - // Increase the backoff time for the next retry. - _backoffTime == 0 ? _backoffTime++ : _backoffTime *= 2; + // Mark connection as disconnected for UI + _isConnected = false; - // Increment the retry count and attempt the reconnect. + // Cancel chat delay countdown when disconnecting + _cancelChatDelayCountdown(); + _chatDelaySyncCompleted = false; + + // Reset send state — any in-flight message is unrecoverable across + // a disconnect, so don't leave the user blocked waiting for an ack + // that will never arrive. + _sendingTimeoutTimer?.cancel(); + _isWaitingForAck = false; + toSend = null; + _pendingSentMessageText = null; + + if (_shouldDisconnect) { + _sevenTVChannel?.sink.close(1000); + return; + } + + if (_retries >= _maxRetries) { + FirebaseCrashlytics.instance.log( + 'WebSocket reconnection failed after $_maxRetries attempts, channel=$channelName', + ); + // Remove the reconnect message before showing final disconnect notice + if (_reconnectMessage != null) { + final index = _messages.indexOf(_reconnectMessage!); + if (index != -1) _messages.removeAt(index); + _reconnectMessage = null; + } + _reconnectStartTime = null; + + // Add directly to messages, not buffer, so it shows immediately + _messages.add( + IRCMessage.createNotice( + message: 'Chat disconnected. Please check your connection.', + actionCallback: () { + _retries = 0; + _backoffTime = 0; + connectToChat(isReconnect: true); + }, + actionLabel: 'Reconnect', + ), + ); + return; + } + + // Increment the retry count. _retries++; - _messageBuffer.add(IRCMessage.createNotice( - message: 'Reconnecting to chat (attempt $_retries)...')); + FirebaseCrashlytics.instance.log( + 'WebSocket reconnecting: attempt $_retries/$_maxRetries, channel=$channelName', + ); + + // Start exponential backoff only after first failed attempt (capped at 8s). + // First retry is immediate to catch brief network hiccups. + if (_retries > 1) { + final newBackoff = _backoffTime == 0 ? 1 : _backoffTime * 2; + _backoffTime = newBackoff > 8 ? 8 : newBackoff; + } + + // Helper to update the single reconnect message in place (or add if first time) + void updateReconnectMessage(String text) { + final msg = IRCMessage.createNotice(message: text); + if (_reconnectMessage == null) { + _reconnectStartTime ??= + DateTime.now(); // Record when reconnection started + _reconnectMessage = msg; + _messages.add(_reconnectMessage!); + } else { + final index = _messages.indexOf(_reconnectMessage!); + if (index != -1) { + _reconnectMessage = msg; + _messages[index] = _reconnectMessage!; + } else { + // Reference was lost (e.g., message limit cleanup) - re-add + _reconnectMessage = msg; + _messages.add(_reconnectMessage!); + } + } + } + + // Countdown phase (if backoff time > 0) + if (_backoffTime > 0) { + var remainingSeconds = _backoffTime; + updateReconnectMessage( + 'Reconnecting in ${remainingSeconds}s... (attempt $_retries of $_maxRetries)', + ); + + // Update countdown every second + await Future.doWhile(() async { + await Future.delayed(const Duration(seconds: 1)); + + // Abort countdown if dispose was called + if (_shouldDisconnect) { + if (_reconnectMessage != null) { + final index = _messages.indexOf(_reconnectMessage!); + if (index != -1) _messages.removeAt(index); + _reconnectMessage = null; + } + _reconnectStartTime = null; + return false; // Exit loop + } + + remainingSeconds--; + + if (remainingSeconds > 0) { + updateReconnectMessage( + 'Reconnecting in ${remainingSeconds}s... (attempt $_retries of $_maxRetries)', + ); + return true; // Continue loop + } + return false; // Exit loop + }); + + // Don't proceed with reconnection if disposed + if (_shouldDisconnect) return; + } + + // Attempting phase - update same message, no add/remove + updateReconnectMessage( + 'Reconnecting... (attempt $_retries of $_maxRetries)', + ); + _channelListener?.cancel(); - connectToChat(); + connectToChat(isReconnect: true); }, ); @@ -412,10 +1253,92 @@ abstract class ChatStoreBase with Store { @action void addMessages() { - if (!_autoScroll || _messageBuffer.isEmpty) return; + _flushDueDelayedCallbacks(); - _messages.addAll(_messageBuffer); - _messageBuffer.clear(); + // Hold messages back while the user is touching the list so the pressed + // message stays put (and alive) for the duration of a long-press. + if (!_autoScroll || _interactionPause.isPaused || messageBuffer.isEmpty) { + return; + } + + _messages.addAll(messageBuffer); + messageBuffer.clear(); + } + + /// Cancels the chat delay countdown and removes the countdown message. + @action + void _cancelChatDelayCountdown() { + _chatDelayCountdownTimer?.cancel(); + _chatDelayCountdownTimer = null; + + // Remove the countdown message if it exists + if (_countdownMessage != null) { + _messages.remove(_countdownMessage); + _countdownMessage = null; + } + } + + /// Starts and manages the chat delay countdown message. + @action + void _startChatDelayCountdown() { + // Cancel any existing countdown first (prevents duplicate messages) + _cancelChatDelayCountdown(); + + // Flush any buffered messages first to ensure countdown is at the bottom + if (messageBuffer.isNotEmpty) { + _messages.addAll(messageBuffer); + messageBuffer.clear(); + } + + var remainingSeconds = _chatDelay.inSeconds; + + // Create and store reference to the countdown message + _countdownMessage = IRCMessage.createNotice( + message: 'Chat will sync in ${remainingSeconds}s...', + ); + _messages.add(_countdownMessage!); + + // Update countdown every second + _chatDelayCountdownTimer = Timer.periodic(const Duration(seconds: 1), ( + timer, + ) { + remainingSeconds--; + + // Use runInAction for proper MobX reactivity in async callbacks + runInAction(() { + // Check if countdown message still exists in the list + // (could be removed by message limit cleanup) + if (_countdownMessage == null || + !_messages.contains(_countdownMessage)) { + timer.cancel(); + _countdownMessage = null; + _chatDelayCountdownTimer = null; + return; + } + + final index = _messages.indexOf(_countdownMessage!); + if (index != -1) { + if (remainingSeconds > 0) { + // Create new message and update reference + _countdownMessage = IRCMessage.createNotice( + message: 'Chat will sync in ${remainingSeconds}s...', + ); + _messages[index] = _countdownMessage!; + } else { + // Remove countdown message when done + _messages.removeAt(index); + _countdownMessage = null; + _chatDelayCountdownTimer = null; + _chatDelaySyncCompleted = true; + timer.cancel(); + } + } else { + _countdownMessage = null; + _chatDelayCountdownTimer = null; + timer.cancel(); + } + }); + }); } /// Sends the given string message by the logged-in user and adds it to [_messages]. @@ -424,32 +1347,121 @@ abstract class ChatStoreBase with Store { // Do not send if the message is blank/empty. if (message.isEmpty) return; - if (_channel == null || _channel?.closeCode != null) { - _messageBuffer.add(IRCMessage.createNotice( - message: 'Failed to send message: disconnected from chat.')); - } else { - // Send the message to the IRC chat room. - _channel?.sink.add('PRIVMSG #$channelName :$message'); - - // Obtain the logged-in user's appearance in chat with USERSTATE and create the full message to render. - var userStateString = _userState.raw; - if (userStateString != null) { - if (message.length > 3 && message.substring(0, 3) == '/me') { - userStateString += - ' :\x01ACTION ${message.replaceRange(0, 3, '').trim()}\x01'; - } else { - userStateString += ' :${message.trim()}'; - } - - final userChatMessage = IRCMessage.fromString(userStateString); - userChatMessage.localEmotes?.addAll(assetsStore.userEmoteToObject); - if (auth.isLoggedIn && auth.user.details != null) { - userChatMessage.tags['user-id'] = auth.user.details!.id; - } - - toSend = userChatMessage; - } + // Prevent double-sending while waiting for server acknowledgment + if (_isWaitingForAck) { + HapticFeedback.mediumImpact(); + updateNotification('Please wait, sending previous message...'); + return; } + + if (_channel == null || _channel?.closeCode != null) { + messageBuffer.add( + IRCMessage.createNotice( + message: + 'Cannot send message - chat is disconnected. Reconnecting...', + ), + ); + return; + } + + // Set waiting state for UI feedback + _isWaitingForAck = true; + + // Send the message to the IRC chat room. + _channel?.sink.add( + '${replyingToMessage != null ? '@reply-parent-msg-id=${replyingToMessage!.tags['id']} ' : ''}PRIVMSG #$channelName :$message', + ); + + // Text field and reply state cleared on USERSTATE confirmation (not optimistically) + // to preserve message text if Twitch rejects it (e.g., slow mode) + + // Fallback in case the confirming USERSTATE never arrives. Twitch can omit + // or drop the USERSTATE that acknowledges a send — intermittently, and more + // often under a long chat delay — even though the message was delivered. + // A missing ack is therefore NOT evidence of failure, so rather than cry + // wolf with a "may not have been sent" error (which pushes the user to + // resend and double-post), optimistically finalize the send: render the + // local echo and clear the pending state. Genuine rejections almost always + // arrive first — the rejection NOTICE bypasses the chat delay, so it lands + // well within this window and clears the pending send before the timeout. A + // rejection arriving after the timeout has finalized is treated as sent (the + // echo stays), an accepted edge given how early NOTICEs arrive. Scaled by + // the chat delay so a legitimately delayed USERSTATE is given time to land. + _sendingTimeoutTimer?.cancel(); + _sendingTimeoutTimer = Timer(const Duration(seconds: 10) + _chatDelay, () { + if (_shouldDisconnect || !_isWaitingForAck) return; + runInAction(() => _finalizeSend(null)); + }); + + // Build the optimistic local echo from the current USERSTATE. If the + // message is sent before this session's first USERSTATE arrives, + // _buildLocalEcho returns null and the echo is built later, when the + // confirming USERSTATE lands (see the Command.userState handler). The raw + // text is retained so that deferred build is possible. + _pendingSentMessageText = message; + toSend = _buildLocalEcho(message); + } + + /// Renders the pending outgoing message's local echo (building it lazily if + /// it couldn't be built at send time) and clears all send-pending state. + /// Shared by the USERSTATE confirmation and the optimistic timeout fallback. + /// [serverMessageId] is the `id` tag from the confirming USERSTATE, or null + /// when finalizing optimistically without one. + void _finalizeSend(String? serverMessageId) { + toSend ??= _pendingSentMessageText != null + ? _buildLocalEcho(_pendingSentMessageText!) + : null; + if (toSend != null) { + // Prefer the server id; fall back to any id already set, then a local one. + toSend!.tags['id'] = + serverMessageId ?? + toSend!.tags['id'] ?? + 'local-${DateTime.now().millisecondsSinceEpoch}'; + messageBuffer.add(toSend!); + toSend = null; + } + _pendingSentMessageText = null; + _sendingTimeoutTimer?.cancel(); + _isWaitingForAck = false; + textController.clear(); + replyingToMessage = null; + } + + /// Builds the optimistic local echo of an outgoing [message] from the current + /// USERSTATE, or null if no USERSTATE has been received yet this session + /// (the echo can't be rendered without the user's badges/color/display-name). + IRCMessage? _buildLocalEcho(String message) { + var userStateString = _userState.raw; + if (userStateString == null) return null; + + if (message == '/me' || message.startsWith('/me ')) { + userStateString += + ' :\x01ACTION ${message.replaceRange(0, 3, '').trim()}\x01'; + } else { + userStateString += + ' :${replyingToMessage?.tags['display-name'] != null ? '@${replyingToMessage!.tags['display-name']} ' : ''}${message.trim()}'; + } + + final userChatMessage = IRCMessage.fromString(userStateString); + userChatMessage.localEmotes?.addAll(assetsStore.userEmoteToObject); + if (auth.isLoggedIn && auth.user.details != null) { + userChatMessage.tags['user-id'] = auth.user.details!.id; + } + + if (replyingToMessage != null && replyingToMessage!.tags['id'] != null) { + userChatMessage.tags['reply-parent-msg-id'] = + replyingToMessage!.tags['id']!; + userChatMessage.tags['reply-parent-display-name'] = + replyingToMessage!.tags['display-name'] ?? ''; + userChatMessage.tags['reply-parent-msg-body'] = + replyingToMessage!.message ?? ''; + } + + // Ensure a timestamp so merged-mode sorting works correctly. + userChatMessage.tags['tmi-sent-ts'] ??= + '${DateTime.now().millisecondsSinceEpoch}'; + + return userChatMessage; } /// Adds the given [emote] to the chat textfield. @@ -470,12 +1482,15 @@ abstract class ChatStoreBase with Store { } assetsStore.recentEmotes - ..removeWhere((recentEmote) => - recentEmote.name == emote.name && recentEmote.type == emote.type) + ..removeWhere( + (recentEmote) => + recentEmote.name == emote.name && recentEmote.type == emote.type, + ) ..insert(0, emote); textController.selection = TextSelection.fromPosition( - TextPosition(offset: textController.text.length)); + TextPosition(offset: textController.text.length), + ); } /// Cancels the previous notification/timer and creates a new one with the provided [notificationMessage]. @@ -485,50 +1500,46 @@ abstract class ChatStoreBase with Store { // when copying messages repeatedly. _notificationTimer?.cancel(); - // If empty, clear the notification and don't make a new timer (empty message means cancelling the notification). - if (notificationMessage.isEmpty) { - _notification = null; - return; - } - - // Set the new notification message and create a new timer that will dismiss it after 2 seconds. + // Set the new notification message and create a new timer that will dismiss it after 3 seconds. _notification = notificationMessage; - _notificationTimer = - Timer(const Duration(seconds: 2), () => _notification = null); + _notificationTimer = Timer( + const Duration(seconds: 5), + () => _notification = null, + ); } - /// Updates the sleep timer with [sleepHours] and [sleepMinutes]. + /// Clears the current notification immediately. + @action + void clearNotification() { + _notificationTimer?.cancel(); + _notification = null; + } + + /// Updates the sleep timer with the given [duration]. /// Calls [onTimerFinished] when the sleep timer completes. @action - void updateSleepTimer({required void Function() onTimerFinished}) { - // If hours and minutes are 0, do nothing. - if (sleepHours == 0 && sleepMinutes == 0) return; - + void updateSleepTimer({ + required Duration duration, + required VoidCallback onTimerFinished, + }) { // If there is an ongoing timer, cancel it since it'll be replaced. if (sleepTimer != null) cancelSleepTimer(); // Update the new time remaining - timeRemaining = Duration(hours: sleepHours, minutes: sleepMinutes); - - // Reset the hours and minutes in the dropdown buttons. - sleepHours = 0; - sleepMinutes = 0; + timeRemaining = duration; // Set a periodic timer that will update the time remaining every second. - sleepTimer = Timer.periodic( - const Duration(seconds: 1), - (timer) { - // If the timer is up, cancel the timer and exit the app. - if (timeRemaining.inSeconds == 0) { - timer.cancel(); - onTimerFinished(); - return; - } + sleepTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + // If the timer is up, cancel the timer and exit the app. + if (timeRemaining.inSeconds == 0) { + timer.cancel(); + onTimerFinished(); + return; + } - // Decrement the time remaining. - timeRemaining = Duration(seconds: timeRemaining.inSeconds - 1); - }, - ); + // Decrement the time remaining. + timeRemaining = Duration(seconds: timeRemaining.inSeconds - 1); + }); } /// Cancels the sleep timer and resets the time remaining. @@ -538,15 +1549,42 @@ abstract class ChatStoreBase with Store { timeRemaining = const Duration(); } + @action + Future getRecentMessage() async { + try { + final recentMessages = await twitchApi.getRecentMessages( + userLogin: channelName, + ); + + for (final message in recentMessages) { + _handleIRCData(message); + } + } catch (e) { + debugPrint('Failed to fetch recent messages: $e'); + } + } + /// Closes and disposes all the channels and controllers used by the store. void dispose() { - _messageBufferTimer.cancel(); + _shouldDisconnect = true; + + _interactionPause.reset(); + + _clearPendingDelayedCallbacks(); + + _messageBufferTimer?.cancel(); _notificationTimer?.cancel(); + _sendingTimeoutTimer?.cancel(); + _cancelChatDelayCountdown(); sleepTimer?.cancel(); - _channel?.sink.close(1001); - _channel = null; + // Cancel WebSocket subscriptions _channelListener?.cancel(); + _sevenTVChannelListener?.cancel(); + + // Close WebSocket channels + _channel?.sink.close(1000); + _sevenTVChannel?.sink.close(1000); for (final reactionDisposer in reactions) { reactionDisposer(); @@ -558,8 +1596,24 @@ abstract class ChatStoreBase with Store { assetsStore.dispose(); chatDetailsStore.dispose(); + } - // Disable wakelock so that the sleep timer will function properly. - Wakelock.disable(); + /// Unfocuses the text field. + @action + void unfocusInput() { + textFieldFocusNode.unfocus(); + } + + /// Requests focus for the text field. + @action + void safeRequestFocus() { + textFieldFocusNode.requestFocus(); } } + +class _DelayedCallback { + _DelayedCallback({required this.releaseAt, required this.callback}); + + final DateTime releaseAt; + final void Function() callback; +} diff --git a/lib/screens/channel/chat/stores/chat_store.g.dart b/lib/screens/channel/chat/stores/chat_store.g.dart index 39a130ca..826915be 100644 --- a/lib/screens/channel/chat/stores/chat_store.g.dart +++ b/lib/screens/channel/chat/stores/chat_store.g.dart @@ -12,45 +12,39 @@ mixin _$ChatStore on ChatStoreBase, Store { Computed>? _$renderMessagesComputed; @override - List get renderMessages => (_$renderMessagesComputed ??= - Computed>(() => super.renderMessages, - name: 'ChatStoreBase.renderMessages')) - .value; - - late final _$sleepHoursAtom = - Atom(name: 'ChatStoreBase.sleepHours', context: context); + List get renderMessages => + (_$renderMessagesComputed ??= Computed>( + () => super.renderMessages, + name: 'ChatStoreBase.renderMessages', + )).value; + Computed>? _$matchingEmotesComputed; @override - int get sleepHours { - _$sleepHoursAtom.reportRead(); - return super.sleepHours; - } + List get matchingEmotes => + (_$matchingEmotesComputed ??= Computed>( + () => super.matchingEmotes, + name: 'ChatStoreBase.matchingEmotes', + )).value; + Computed>? _$matchingChattersComputed; @override - set sleepHours(int value) { - _$sleepHoursAtom.reportWrite(value, super.sleepHours, () { - super.sleepHours = value; - }); - } - - late final _$sleepMinutesAtom = - Atom(name: 'ChatStoreBase.sleepMinutes', context: context); + List get matchingChatters => + (_$matchingChattersComputed ??= Computed>( + () => super.matchingChatters, + name: 'ChatStoreBase.matchingChatters', + )).value; + Computed? _$bottomBarHeightComputed; @override - int get sleepMinutes { - _$sleepMinutesAtom.reportRead(); - return super.sleepMinutes; - } + double get bottomBarHeight => (_$bottomBarHeightComputed ??= Computed( + () => super.bottomBarHeight, + name: 'ChatStoreBase.bottomBarHeight', + )).value; - @override - set sleepMinutes(int value) { - _$sleepMinutesAtom.reportWrite(value, super.sleepMinutes, () { - super.sleepMinutes = value; - }); - } - - late final _$timeRemainingAtom = - Atom(name: 'ChatStoreBase.timeRemaining', context: context); + late final _$timeRemainingAtom = Atom( + name: 'ChatStoreBase.timeRemaining', + context: context, + ); @override Duration get timeRemaining { @@ -65,8 +59,10 @@ mixin _$ChatStore on ChatStoreBase, Store { }); } - late final _$_notificationAtom = - Atom(name: 'ChatStoreBase._notification', context: context); + late final _$_notificationAtom = Atom( + name: 'ChatStoreBase._notification', + context: context, + ); String? get notification { _$_notificationAtom.reportRead(); @@ -83,8 +79,10 @@ mixin _$ChatStore on ChatStoreBase, Store { }); } - late final _$_messagesAtom = - Atom(name: 'ChatStoreBase._messages', context: context); + late final _$_messagesAtom = Atom( + name: 'ChatStoreBase._messages', + context: context, + ); ObservableList get messages { _$_messagesAtom.reportRead(); @@ -101,8 +99,10 @@ mixin _$ChatStore on ChatStoreBase, Store { }); } - late final _$_autoScrollAtom = - Atom(name: 'ChatStoreBase._autoScroll', context: context); + late final _$_autoScrollAtom = Atom( + name: 'ChatStoreBase._autoScroll', + context: context, + ); bool get autoScroll { _$_autoScrollAtom.reportRead(); @@ -119,8 +119,30 @@ mixin _$ChatStore on ChatStoreBase, Store { }); } - late final _$_showSendButtonAtom = - Atom(name: 'ChatStoreBase._showSendButton', context: context); + late final _$_inputTextAtom = Atom( + name: 'ChatStoreBase._inputText', + context: context, + ); + + String get inputText { + _$_inputTextAtom.reportRead(); + return super._inputText; + } + + @override + String get _inputText => inputText; + + @override + set _inputText(String value) { + _$_inputTextAtom.reportWrite(value, super._inputText, () { + super._inputText = value; + }); + } + + late final _$_showSendButtonAtom = Atom( + name: 'ChatStoreBase._showSendButton', + context: context, + ); bool get showSendButton { _$_showSendButtonAtom.reportRead(); @@ -137,8 +159,10 @@ mixin _$ChatStore on ChatStoreBase, Store { }); } - late final _$_showEmoteAutocompleteAtom = - Atom(name: 'ChatStoreBase._showEmoteAutocomplete', context: context); + late final _$_showEmoteAutocompleteAtom = Atom( + name: 'ChatStoreBase._showEmoteAutocomplete', + context: context, + ); bool get showEmoteAutocomplete { _$_showEmoteAutocompleteAtom.reportRead(); @@ -150,14 +174,19 @@ mixin _$ChatStore on ChatStoreBase, Store { @override set _showEmoteAutocomplete(bool value) { - _$_showEmoteAutocompleteAtom - .reportWrite(value, super._showEmoteAutocomplete, () { - super._showEmoteAutocomplete = value; - }); + _$_showEmoteAutocompleteAtom.reportWrite( + value, + super._showEmoteAutocomplete, + () { + super._showEmoteAutocomplete = value; + }, + ); } - late final _$_showMentionAutocompleteAtom = - Atom(name: 'ChatStoreBase._showMentionAutocomplete', context: context); + late final _$_showMentionAutocompleteAtom = Atom( + name: 'ChatStoreBase._showMentionAutocomplete', + context: context, + ); bool get showMentionAutocomplete { _$_showMentionAutocompleteAtom.reportRead(); @@ -169,14 +198,99 @@ mixin _$ChatStore on ChatStoreBase, Store { @override set _showMentionAutocomplete(bool value) { - _$_showMentionAutocompleteAtom - .reportWrite(value, super._showMentionAutocomplete, () { - super._showMentionAutocomplete = value; + _$_showMentionAutocompleteAtom.reportWrite( + value, + super._showMentionAutocomplete, + () { + super._showMentionAutocomplete = value; + }, + ); + } + + late final _$_isInSharedChatModeAtom = Atom( + name: 'ChatStoreBase._isInSharedChatMode', + context: context, + ); + + bool get isInSharedChatMode { + _$_isInSharedChatModeAtom.reportRead(); + return super._isInSharedChatMode; + } + + @override + bool get _isInSharedChatMode => isInSharedChatMode; + + @override + set _isInSharedChatMode(bool value) { + _$_isInSharedChatModeAtom.reportWrite(value, super._isInSharedChatMode, () { + super._isInSharedChatMode = value; }); } - late final _$_userStateAtom = - Atom(name: 'ChatStoreBase._userState', context: context); + 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, + ); USERSTATE get userState { _$_userStateAtom.reportRead(); @@ -193,8 +307,10 @@ mixin _$ChatStore on ChatStoreBase, Store { }); } - late final _$expandChatAtom = - Atom(name: 'ChatStoreBase.expandChat', context: context); + late final _$expandChatAtom = Atom( + name: 'ChatStoreBase.expandChat', + context: context, + ); @override bool get expandChat { @@ -209,21 +325,78 @@ mixin _$ChatStore on ChatStoreBase, Store { }); } - late final _$getAssetsAsyncAction = - AsyncAction('ChatStoreBase.getAssets', context: context); + late final _$replyingToMessageAtom = Atom( + name: 'ChatStoreBase.replyingToMessage', + context: context, + ); + + @override + IRCMessage? get replyingToMessage { + _$replyingToMessageAtom.reportRead(); + return super.replyingToMessage; + } + + @override + set replyingToMessage(IRCMessage? value) { + _$replyingToMessageAtom.reportWrite(value, super.replyingToMessage, () { + super.replyingToMessage = value; + }); + } + + late final _$getAssetsAsyncAction = AsyncAction( + 'ChatStoreBase.getAssets', + context: context, + ); @override Future getAssets() { return _$getAssetsAsyncAction.run(() => super.getAssets()); } - late final _$ChatStoreBaseActionController = - ActionController(name: 'ChatStoreBase', context: context); + late final _$connectToChatAsyncAction = AsyncAction( + 'ChatStoreBase.connectToChat', + context: context, + ); + + @override + Future connectToChat({bool isReconnect = false}) { + return _$connectToChatAsyncAction.run( + () => super.connectToChat(isReconnect: isReconnect), + ); + } + + late final _$getRecentMessageAsyncAction = AsyncAction( + 'ChatStoreBase.getRecentMessage', + context: context, + ); + + @override + Future getRecentMessage() { + return _$getRecentMessageAsyncAction.run(() => super.getRecentMessage()); + } + + late final _$ChatStoreBaseActionController = ActionController( + name: 'ChatStoreBase', + context: context, + ); + + @override + void revealMessage(String id) { + final _$actionInfo = _$ChatStoreBaseActionController.startAction( + name: 'ChatStoreBase.revealMessage', + ); + try { + return super.revealMessage(id); + } finally { + _$ChatStoreBaseActionController.endAction(_$actionInfo); + } + } @override void _handleIRCData(String data) { final _$actionInfo = _$ChatStoreBaseActionController.startAction( - name: 'ChatStoreBase._handleIRCData'); + name: 'ChatStoreBase._handleIRCData', + ); try { return super._handleIRCData(data); } finally { @@ -234,7 +407,8 @@ mixin _$ChatStore on ChatStoreBase, Store { @override void resumeScroll() { final _$actionInfo = _$ChatStoreBaseActionController.startAction( - name: 'ChatStoreBase.resumeScroll'); + name: 'ChatStoreBase.resumeScroll', + ); try { return super.resumeScroll(); } finally { @@ -243,11 +417,12 @@ mixin _$ChatStore on ChatStoreBase, Store { } @override - void connectToChat() { + void listenToSevenTVEmoteSet({required String emoteSetId}) { final _$actionInfo = _$ChatStoreBaseActionController.startAction( - name: 'ChatStoreBase.connectToChat'); + name: 'ChatStoreBase.listenToSevenTVEmoteSet', + ); try { - return super.connectToChat(); + return super.listenToSevenTVEmoteSet(emoteSetId: emoteSetId); } finally { _$ChatStoreBaseActionController.endAction(_$actionInfo); } @@ -256,7 +431,8 @@ mixin _$ChatStore on ChatStoreBase, Store { @override void addMessages() { final _$actionInfo = _$ChatStoreBaseActionController.startAction( - name: 'ChatStoreBase.addMessages'); + name: 'ChatStoreBase.addMessages', + ); try { return super.addMessages(); } finally { @@ -264,10 +440,35 @@ mixin _$ChatStore on ChatStoreBase, Store { } } + @override + void _cancelChatDelayCountdown() { + final _$actionInfo = _$ChatStoreBaseActionController.startAction( + name: 'ChatStoreBase._cancelChatDelayCountdown', + ); + try { + return super._cancelChatDelayCountdown(); + } finally { + _$ChatStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void _startChatDelayCountdown() { + final _$actionInfo = _$ChatStoreBaseActionController.startAction( + name: 'ChatStoreBase._startChatDelayCountdown', + ); + try { + return super._startChatDelayCountdown(); + } finally { + _$ChatStoreBaseActionController.endAction(_$actionInfo); + } + } + @override void sendMessage(String message) { final _$actionInfo = _$ChatStoreBaseActionController.startAction( - name: 'ChatStoreBase.sendMessage'); + name: 'ChatStoreBase.sendMessage', + ); try { return super.sendMessage(message); } finally { @@ -278,7 +479,8 @@ mixin _$ChatStore on ChatStoreBase, Store { @override void addEmote(Emote emote, {bool autocompleteMode = false}) { final _$actionInfo = _$ChatStoreBaseActionController.startAction( - name: 'ChatStoreBase.addEmote'); + name: 'ChatStoreBase.addEmote', + ); try { return super.addEmote(emote, autocompleteMode: autocompleteMode); } finally { @@ -289,7 +491,8 @@ mixin _$ChatStore on ChatStoreBase, Store { @override void updateNotification(String notificationMessage) { final _$actionInfo = _$ChatStoreBaseActionController.startAction( - name: 'ChatStoreBase.updateNotification'); + name: 'ChatStoreBase.updateNotification', + ); try { return super.updateNotification(notificationMessage); } finally { @@ -298,11 +501,30 @@ mixin _$ChatStore on ChatStoreBase, Store { } @override - void updateSleepTimer({required void Function() onTimerFinished}) { + void clearNotification() { final _$actionInfo = _$ChatStoreBaseActionController.startAction( - name: 'ChatStoreBase.updateSleepTimer'); + name: 'ChatStoreBase.clearNotification', + ); try { - return super.updateSleepTimer(onTimerFinished: onTimerFinished); + return super.clearNotification(); + } finally { + _$ChatStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void updateSleepTimer({ + required Duration duration, + required VoidCallback onTimerFinished, + }) { + final _$actionInfo = _$ChatStoreBaseActionController.startAction( + name: 'ChatStoreBase.updateSleepTimer', + ); + try { + return super.updateSleepTimer( + duration: duration, + onTimerFinished: onTimerFinished, + ); } finally { _$ChatStoreBaseActionController.endAction(_$actionInfo); } @@ -311,7 +533,8 @@ mixin _$ChatStore on ChatStoreBase, Store { @override void cancelSleepTimer() { final _$actionInfo = _$ChatStoreBaseActionController.startAction( - name: 'ChatStoreBase.cancelSleepTimer'); + name: 'ChatStoreBase.cancelSleepTimer', + ); try { return super.cancelSleepTimer(); } finally { @@ -319,14 +542,40 @@ mixin _$ChatStore on ChatStoreBase, Store { } } + @override + void unfocusInput() { + final _$actionInfo = _$ChatStoreBaseActionController.startAction( + name: 'ChatStoreBase.unfocusInput', + ); + try { + return super.unfocusInput(); + } finally { + _$ChatStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void safeRequestFocus() { + final _$actionInfo = _$ChatStoreBaseActionController.startAction( + name: 'ChatStoreBase.safeRequestFocus', + ); + try { + return super.safeRequestFocus(); + } finally { + _$ChatStoreBaseActionController.endAction(_$actionInfo); + } + } + @override String toString() { return ''' -sleepHours: ${sleepHours}, -sleepMinutes: ${sleepMinutes}, timeRemaining: ${timeRemaining}, expandChat: ${expandChat}, -renderMessages: ${renderMessages} +replyingToMessage: ${replyingToMessage}, +renderMessages: ${renderMessages}, +matchingEmotes: ${matchingEmotes}, +matchingChatters: ${matchingChatters}, +bottomBarHeight: ${bottomBarHeight} '''; } } diff --git a/lib/screens/channel/chat/stores/chat_tabs_store.dart b/lib/screens/channel/chat/stores/chat_tabs_store.dart new file mode 100644 index 00000000..ee095442 --- /dev/null +++ b/lib/screens/channel/chat/stores/chat_tabs_store.dart @@ -0,0 +1,754 @@ +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'; +import 'package:frosty/stores/global_assets_store.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:mobx/mobx.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; + +part 'chat_tabs_store.g.dart'; + +/// Lightweight data class for persisting chat tab info to settings. +/// Does NOT include ChatStore since WebSocket connections cannot be persisted. +@JsonSerializable() +class PersistedChatTab { + final String channelId; + final String channelLogin; + final String displayName; + + const PersistedChatTab({ + required this.channelId, + required this.channelLogin, + required this.displayName, + }); + + factory PersistedChatTab.fromJson(Map json) => + _$PersistedChatTabFromJson(json); + Map toJson() => _$PersistedChatTabToJson(this); + + /// Create from a ChatTabInfo (for syncing current tabs to settings). + factory PersistedChatTab.fromChatTabInfo(ChatTabInfo info) => + PersistedChatTab( + channelId: info.channelId, + channelLogin: info.channelLogin, + displayName: info.displayName, + ); +} + +/// Data class holding information about a chat tab. +class ChatTabInfo { + final String channelId; + final String channelLogin; + final String displayName; + + /// 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; + + /// Whether this tab has been activated (chatStore created). + bool get isActivated => chatStore != null; + + ChatTabInfo({ + required this.channelId, + required this.channelLogin, + required this.displayName, + this.chatStore, + required this.isPrimary, + }); +} + +/// 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; + +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; + final FFZApi ffzApi; + final SevenTVApi sevenTVApi; + + /// Global stores passed to each ChatStore. + final AuthStore authStore; + final SettingsStore settingsStore; + final GlobalAssetsStore globalAssetsStore; + + /// The list of open chat tabs. + @readonly + var _tabs = ObservableList(); + + /// The index of the currently active tab. + @observable + var activeTabIndex = 0; + + /// Returns the currently active tab info. + @computed + ChatTabInfo get activeTab => _tabs[activeTabIndex]; + + /// Returns the ChatStore of the currently active tab. + /// The active tab is always activated, so this should never be null. + @computed + ChatStore get activeChatStore => _tabs[activeTabIndex].chatStore!; + + /// Whether we can add more tabs (under the limit). + @computed + bool get canAddTab => _tabs.length < maxTabs; + + /// Whether the tab bar should be visible (more than 1 tab). + @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(); + + /// 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 = []; + + /// 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? _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 get mergedChannelIdToUserTwitch { + final merged = {}; + 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 out, + Set 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 _computeAllMergedMessages() { + final allMessages = []; + final seenIds = {}; + 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 _computeRecentMergedMessages() { + final recent = []; + final seenIds = {}; + 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 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 get tabs => _tabs; + + ChatTabsStoreBase({ + required this.twitchApi, + required this.bttvApi, + required this.ffzApi, + required this.sevenTVApi, + required this.authStore, + required this.settingsStore, + required this.globalAssetsStore, + required String primaryChannelId, + required String primaryChannelLogin, + required String primaryDisplayName, + }) { + // Enable wakelock once for all chat tabs + if (settingsStore.keepScreenAwake) { + WakelockPlus.enable(); + } + + // Initialize with the primary channel tab + _addPrimaryTab( + channelId: primaryChannelId, + channelLogin: primaryChannelLogin, + displayName: primaryDisplayName, + ); + + // Restore persisted secondary tabs if feature is enabled + 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. + ChatStore _createChatStore({ + required String channelId, + required String channelLogin, + required String displayName, + }) { + return ChatStore( + twitchApi: twitchApi, + channelName: channelLogin, + channelId: channelId, + displayName: displayName, + auth: authStore, + settings: settingsStore, + chatDetailsStore: ChatDetailsStore( + twitchApi: twitchApi, + channelName: channelLogin, + ), + assetsStore: ChatAssetsStore( + twitchApi: twitchApi, + ffzApi: ffzApi, + bttvApi: bttvApi, + sevenTVApi: sevenTVApi, + globalAssetsStore: globalAssetsStore, + ), + ); + } + + /// 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 _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) { + if (index < 0 || index >= _tabs.length) return; + + final tab = _tabs[index]; + if (tab.chatStore != null) return; // Already activated + + tab.chatStore = _createChatStore( + channelId: tab.channelId, + channelLogin: tab.channelLogin, + displayName: tab.displayName, + ); + } + + /// Adds the primary tab (called during initialization). + void _addPrimaryTab({ + required String channelId, + required String channelLogin, + required String displayName, + }) { + final chatStore = _createChatStore( + channelId: channelId, + channelLogin: channelLogin, + displayName: displayName, + ); + + _tabs.add( + ChatTabInfo( + channelId: channelId, + channelLogin: channelLogin, + displayName: displayName, + chatStore: chatStore, + isPrimary: true, + ), + ); + } + + /// Restores secondary tabs from settings (lazy - no ChatStore created). + void _restoreSecondaryTabs({required String primaryChannelId}) { + for (final persisted in settingsStore.secondaryTabs) { + // Skip if this is the same as the primary tab (already added) + if (persisted.channelId == primaryChannelId) { + continue; + } + + // Skip if at max capacity + if (_tabs.length >= maxTabs) { + break; + } + + // Add tab without ChatStore (lazy loading) + _tabs.add( + ChatTabInfo( + channelId: persisted.channelId, + channelLogin: persisted.channelLogin, + displayName: persisted.displayName, + isPrimary: false, + ), + ); + } + } + + /// Syncs current secondary tabs to settings for persistence. + void _syncSecondaryTabsToSettings() { + if (!settingsStore.persistChatTabs) return; + + // Get all non-primary tabs and convert to PersistedChatTab + final secondaryTabs = _tabs + .where((tab) => !tab.isPrimary) + .map(PersistedChatTab.fromChatTabInfo) + .toList(); + + // Update settings (autorun will handle persistence) + settingsStore.secondaryTabs = secondaryTabs; + } + + /// Adds a new chat tab for the given channel. + /// Returns true if the tab was added, false if at limit or duplicate. + @action + bool addTab({ + required String channelId, + required String channelLogin, + required String displayName, + }) { + // Check if at max capacity + if (_tabs.length >= maxTabs) { + return false; + } + + // Check for duplicate channel + final existingIndex = _tabs.indexWhere((tab) => tab.channelId == channelId); + if (existingIndex != -1) { + // Switch to existing tab instead of adding duplicate + setActiveTab(existingIndex); + return false; + } + + // Add the new tab (lazy - no ChatStore yet) + _tabs.add( + ChatTabInfo( + channelId: channelId, + channelLogin: channelLogin, + displayName: displayName, + isPrimary: false, + ), + ); + + // Switch to the new tab, snapshotting the outgoing tab's lastSeenMessageId. + final newIndex = _tabs.length - 1; + setActiveTab(newIndex); + + // If in merged mode, fetch the new tab's channel profile + if (mergedMode) { + _fetchTabChannelProfile(_tabs[newIndex]); + } + + // Sync to settings for persistence + _syncSecondaryTabsToSettings(); + + 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 + bool removeTab(int index) { + // Validate index + if (index < 0 || index >= _tabs.length) { + return false; + } + + // Cannot remove primary tab + if (_tabs[index].isPrimary) { + 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; + } else if (activeTabIndex > index) { + activeTabIndex--; + } + + // Ensure the new active tab is activated (in case it was lazy) + activateTab(activeTabIndex); + + // Sync to settings for persistence + _syncSecondaryTabsToSettings(); + + return true; + } + + /// Reorders a tab from oldIndex to newIndex. + /// The primary tab (index 0) cannot be moved, and no tab can be placed before it. + @action + void reorderTab(int oldIndex, int newIndex) { + // 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. + if (oldIndex == newIndex) return; + + final tab = _tabs.removeAt(oldIndex); + _tabs.insert(newIndex, tab); + + // Adjust activeTabIndex to follow the active tab + if (activeTabIndex == oldIndex) { + activeTabIndex = newIndex; + } else if (oldIndex < activeTabIndex && newIndex >= activeTabIndex) { + activeTabIndex -= 1; + } else if (oldIndex > activeTabIndex && newIndex <= activeTabIndex) { + activeTabIndex += 1; + } + + _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 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}) { + 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) { + final currentStore = _tabs[activeTabIndex].chatStore; + if (currentStore != null) { + // Close emote menu if open + currentStore.assetsStore.showEmoteMenu = false; + // Unfocus input + currentStore.unfocusInput(); + // Clear text input + currentStore.textController.clear(); + // Clear reply state + currentStore.replyingToMessage = null; + } + } + + // 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(); + } +} diff --git a/lib/screens/channel/chat/stores/chat_tabs_store.g.dart b/lib/screens/channel/chat/stores/chat_tabs_store.g.dart new file mode 100644 index 00000000..61bfea32 --- /dev/null +++ b/lib/screens/channel/chat/stores/chat_tabs_store.g.dart @@ -0,0 +1,320 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chat_tabs_store.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PersistedChatTab _$PersistedChatTabFromJson(Map json) => + PersistedChatTab( + channelId: json['channelId'] as String, + channelLogin: json['channelLogin'] as String, + displayName: json['displayName'] as String, + ); + +Map _$PersistedChatTabToJson(PersistedChatTab instance) => + { + 'channelId': instance.channelId, + 'channelLogin': instance.channelLogin, + 'displayName': instance.displayName, + }; + +// ************************************************************************** +// StoreGenerator +// ************************************************************************** + +// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers + +mixin _$ChatTabsStore on ChatTabsStoreBase, Store { + Computed? _$activeTabComputed; + + @override + ChatTabInfo get activeTab => (_$activeTabComputed ??= Computed( + () => super.activeTab, + name: 'ChatTabsStoreBase.activeTab', + )).value; + Computed? _$activeChatStoreComputed; + + @override + ChatStore get activeChatStore => + (_$activeChatStoreComputed ??= Computed( + () => super.activeChatStore, + name: 'ChatTabsStoreBase.activeChatStore', + )).value; + Computed? _$canAddTabComputed; + + @override + bool get canAddTab => (_$canAddTabComputed ??= Computed( + () => super.canAddTab, + name: 'ChatTabsStoreBase.canAddTab', + )).value; + Computed? _$showTabBarComputed; + + @override + bool get showTabBar => (_$showTabBarComputed ??= Computed( + () => super.showTabBar, + name: 'ChatTabsStoreBase.showTabBar', + )).value; + Computed>? _$mergedChannelIdToUserTwitchComputed; + + @override + Map get mergedChannelIdToUserTwitch => + (_$mergedChannelIdToUserTwitchComputed ??= + Computed>( + () => super.mergedChannelIdToUserTwitch, + name: 'ChatTabsStoreBase.mergedChannelIdToUserTwitch', + )) + .value; + Computed>? _$mergedMessagesComputed; + + @override + List get mergedMessages => + (_$mergedMessagesComputed ??= Computed>( + () => super.mergedMessages, + name: 'ChatTabsStoreBase.mergedMessages', + )).value; + Computed? _$mergedBufferCountComputed; + + @override + int get mergedBufferCount => (_$mergedBufferCountComputed ??= Computed( + () => super.mergedBufferCount, + name: 'ChatTabsStoreBase.mergedBufferCount', + )).value; + + late final _$_tabsAtom = Atom( + name: 'ChatTabsStoreBase._tabs', + context: context, + ); + + ObservableList get tabs { + _$_tabsAtom.reportRead(); + return super._tabs; + } + + @override + ObservableList get _tabs => tabs; + + @override + set _tabs(ObservableList value) { + _$_tabsAtom.reportWrite(value, super._tabs, () { + super._tabs = value; + }); + } + + late final _$activeTabIndexAtom = Atom( + name: 'ChatTabsStoreBase.activeTabIndex', + context: context, + ); + + @override + int get activeTabIndex { + _$activeTabIndexAtom.reportRead(); + return super.activeTabIndex; + } + + @override + set activeTabIndex(int value) { + _$activeTabIndexAtom.reportWrite(value, super.activeTabIndex, () { + super.activeTabIndex = value; + }); + } + + 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 get mergedRenderedMessages { + _$_mergedRenderedMessagesAtom.reportRead(); + return super._mergedRenderedMessages; + } + + @override + List get _mergedRenderedMessages => mergedRenderedMessages; + + @override + set _mergedRenderedMessages(List 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( + name: 'ChatTabsStoreBase.activateTab', + ); + try { + return super.activateTab(index); + } finally { + _$ChatTabsStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + bool addTab({ + required String channelId, + required String channelLogin, + required String displayName, + }) { + final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction( + name: 'ChatTabsStoreBase.addTab', + ); + try { + return super.addTab( + channelId: channelId, + channelLogin: channelLogin, + displayName: displayName, + ); + } finally { + _$ChatTabsStoreBaseActionController.endAction(_$actionInfo); + } + } + + @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( + name: 'ChatTabsStoreBase.removeTab', + ); + try { + return super.removeTab(index); + } finally { + _$ChatTabsStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void reorderTab(int oldIndex, int newIndex) { + final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction( + name: 'ChatTabsStoreBase.reorderTab', + ); + try { + return super.reorderTab(oldIndex, newIndex); + } finally { + _$ChatTabsStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void setActiveTab(int index, {bool silent = false}) { + final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction( + name: 'ChatTabsStoreBase.setActiveTab', + ); + try { + return super.setActiveTab(index, silent: silent); + } finally { + _$ChatTabsStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + String toString() { + return ''' +activeTabIndex: ${activeTabIndex}, +mergedMode: ${mergedMode}, +activeTab: ${activeTab}, +activeChatStore: ${activeChatStore}, +canAddTab: ${canAddTab}, +showTabBar: ${showTabBar}, +mergedChannelIdToUserTwitch: ${mergedChannelIdToUserTwitch}, +mergedMessages: ${mergedMessages}, +mergedBufferCount: ${mergedBufferCount} + '''; + } +} diff --git a/lib/screens/channel/chat/stores/shared_chat_bubble_state.dart b/lib/screens/channel/chat/stores/shared_chat_bubble_state.dart new file mode 100644 index 00000000..6d18c655 --- /dev/null +++ b/lib/screens/channel/chat/stores/shared_chat_bubble_state.dart @@ -0,0 +1,45 @@ +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(); + + /// Channel ids whose messages are filtered out of the feed entirely. + final hiddenChannelIds = ObservableSet(); + + /// 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(); + } +} diff --git a/lib/screens/channel/chat/widgets/add_chat_dialog.dart b/lib/screens/channel/chat/widgets/add_chat_dialog.dart new file mode 100644 index 00000000..cbf9927b --- /dev/null +++ b/lib/screens/channel/chat/widgets/add_chat_dialog.dart @@ -0,0 +1,256 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/models/channel.dart'; +import 'package:frosty/utils.dart'; +import 'package:frosty/utils/modal_bottom_sheet.dart'; +import 'package:frosty/widgets/alert_message.dart'; +import 'package:frosty/widgets/animated_scroll_border.dart'; +import 'package:frosty/widgets/live_indicator.dart'; +import 'package:frosty/widgets/profile_picture.dart'; +import 'package:frosty/widgets/section_header.dart'; +import 'package:frosty/widgets/skeleton_loader.dart'; +import 'package:frosty/widgets/uptime.dart'; + +/// Data class returned when a channel is selected in the AddChatSheet. +class AddChatResult { + final String channelId; + final String channelLogin; + final String displayName; + + const AddChatResult({ + required this.channelId, + required this.channelLogin, + required this.displayName, + }); +} + +/// Bottom sheet for adding a new chat tab by searching for a Twitch channel. +class AddChatSheet extends StatefulWidget { + final TwitchApi twitchApi; + + const AddChatSheet({super.key, required this.twitchApi}); + + /// Shows the bottom sheet and returns the selected channel info, or null if cancelled. + static Future show( + BuildContext context, + TwitchApi twitchApi, + ) { + return showModalBottomSheetWithProperFocus( + context: context, + isScrollControlled: true, + builder: (context) => AddChatSheet(twitchApi: twitchApi), + ); + } + + @override + State createState() => _AddChatSheetState(); +} + +class _AddChatSheetState extends State { + final _textController = TextEditingController(); + final _focusNode = FocusNode(); + final _scrollController = ScrollController(); + + Timer? _debounce; + bool _isLoading = false; + String? _errorMessage; + List _results = []; + + @override + void initState() { + super.initState(); + _textController.addListener(() => setState(() {})); + _focusNode.addListener(() => setState(() {})); + } + + @override + void dispose() { + _debounce?.cancel(); + _textController.dispose(); + _focusNode.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + void _onSearchChanged(String query) { + // Cancel any existing debounce + _debounce?.cancel(); + + if (query.isEmpty) { + setState(() { + _results = []; + _errorMessage = null; + _isLoading = false; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + // Debounce the search by 300ms + _debounce = Timer(const Duration(milliseconds: 300), () async { + try { + final channels = await widget.twitchApi.searchChannels(query: query); + if (mounted) { + setState(() { + _results = channels; + _isLoading = false; + _errorMessage = null; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _results = []; + _isLoading = false; + _errorMessage = 'Failed to search channels'; + }); + } + } + }); + } + + void _selectChannel(ChannelQuery channel) { + Navigator.of(context).pop( + AddChatResult( + channelId: channel.id, + channelLogin: channel.broadcasterLogin, + displayName: channel.displayName, + ), + ); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.8, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionHeader('Add Chat', isFirst: true), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: TextField( + controller: _textController, + focusNode: _focusNode, + autocorrect: false, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.search_rounded), + hintText: 'Search for a channel', + suffixIcon: + _focusNode.hasFocus || _textController.text.isNotEmpty + ? IconButton( + tooltip: _textController.text.isEmpty + ? 'Cancel' + : 'Clear', + onPressed: () { + if (_textController.text.isEmpty) { + _focusNode.unfocus(); + } + _textController.clear(); + _onSearchChanged(''); + }, + icon: const Icon(Icons.close_rounded), + ) + : null, + ), + onChanged: _onSearchChanged, + textInputAction: TextInputAction.search, + ), + ), + AnimatedScrollBorder(scrollController: _scrollController), + Expanded(child: _buildResults()), + ], + ), + ), + ); + } + + Widget _buildResults() { + if (_textController.text.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + 'Search for a channel to add', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } + + if (_isLoading) { + return ListView.builder( + controller: _scrollController, + itemCount: 8, + itemBuilder: (context, index) => ChannelSkeletonLoader(index: index), + ); + } + + if (_errorMessage != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: AlertMessage(message: _errorMessage!), + ), + ); + } + + if (_results.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + 'No channels found', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } + + return ListView.builder( + controller: _scrollController, + padding: EdgeInsets.zero, + itemCount: _results.length, + itemBuilder: (context, index) { + final channel = _results[index]; + final displayName = getReadableName( + channel.displayName, + channel.broadcasterLogin, + ); + + return ListTile( + leading: ProfilePicture( + userLogin: channel.broadcasterLogin, + radius: 16, + ), + title: Text(displayName), + subtitle: channel.isLive + ? Row( + spacing: 6, + children: [ + const LiveIndicator(), + Uptime(startTime: channel.startedAt), + ], + ) + : null, + onTap: () => _selectChannel(channel), + ); + }, + ); + } +} + +// Keep backward compatibility alias +typedef AddChatDialog = AddChatSheet; diff --git a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart index 9c8d7de4..7f5365f2 100644 --- a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart +++ b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart @@ -1,84 +1,174 @@ +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'; import 'package:frosty/constants.dart'; +import 'package:frosty/models/irc.dart'; import 'package:frosty/screens/channel/chat/details/chat_details.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; -import 'package:frosty/widgets/button.dart'; -import 'package:frosty/widgets/cached_image.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; - const ChatBottomBar({Key? key, required this.chatStore}) : super(key: key); + /// Callback to add a new chat tab. + /// 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 Widget build(BuildContext context) { - final emoteMenuButton = Tooltip( - message: 'Emote menu', - preferBelow: false, - child: IconButton( - color: chatStore.assetsStore.showEmoteMenu - ? Theme.of(context).colorScheme.secondary - : null, - icon: Icon(chatStore.assetsStore.showEmoteMenu - ? Icons.emoji_emotions_rounded - : Icons.emoji_emotions_outlined), - onPressed: () { - FocusScope.of(context).unfocus(); - chatStore.assetsStore.showEmoteMenu = - !chatStore.assetsStore.showEmoteMenu; - }, - ), - ); + final isEmotesEnabled = + chatStore.settings.showTwitchEmotes || + chatStore.settings.show7TVEmotes || + chatStore.settings.showBTTVEmotes || + chatStore.settings.showFFZEmotes; + + final emoteMenuButton = isEmotesEnabled + ? Tooltip( + message: 'Emote menu', + preferBelow: false, + child: IconButton( + color: chatStore.assetsStore.showEmoteMenu + ? Theme.of(context).colorScheme.secondary + : null, + icon: Icon( + chatStore.assetsStore.showEmoteMenu + ? Icons.emoji_emotions_rounded + : Icons.emoji_emotions_outlined, + ), + onPressed: () { + chatStore.unfocusInput(); + chatStore.assetsStore.showEmoteMenu = + !chatStore.assetsStore.showEmoteMenu; + }, + ), + ) + : null; return Observer( builder: (context) { - final matchingEmotes = [ - ...chatStore.assetsStore.userEmoteToObject.values, - ...chatStore.assetsStore.bttvEmotes, - ...chatStore.assetsStore.ffzEmotes, - ...chatStore.assetsStore.sevenTVEmotes - ] - .where((emote) => emote.name.toLowerCase().contains( - chatStore.textController.text.split(' ').last.toLowerCase())) - .toList(); + final matchingEmotes = chatStore.matchingEmotes; + final matchingChatters = chatStore.matchingChatters; - final matchingChatters = chatStore.chatDetailsStore.chatUsers - .where((chatter) => chatter.contains(chatStore.textController.text - .split(' ') - .last - .replaceFirst('@', '') - .toLowerCase())) - .toList(); + final isFullscreenOverlay = + chatStore.settings.fullScreen && context.isLandscape; - return Column( + 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; + + 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, + ), + ), + ), + // 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), + ), + ], + ), + ), + ), + ], + // 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( - height: 1.0, - thickness: 1.0, - ), - SizedBox( - height: 50, - child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 5.0), - itemCount: matchingEmotes.length, - scrollDirection: Axis.horizontal, - itemBuilder: (context, index) => InkWell( - onTap: () => chatStore.addEmote(matchingEmotes[index], - autocompleteMode: true), - child: Tooltip( - message: matchingEmotes[index].name, - preferBelow: false, + 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(); + + IRCMessage.showEmoteDetailsBottomSheet( + context, + emote: matchingEmotes[index], + launchExternal: chatStore.settings.launchUrlExternal, + ); + }, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 5.0), + padding: const EdgeInsets.symmetric(horizontal: 8), child: Center( child: FrostyCachedNetworkImage( imageUrl: matchingEmotes[index].url, useFade: false, - height: matchingEmotes[index].height?.toDouble() ?? + height: + matchingEmotes[index].height?.toDouble() ?? defaultEmoteSize, width: matchingEmotes[index].width?.toDouble(), ), @@ -92,108 +182,243 @@ class ChatBottomBar extends StatelessWidget { if (chatStore.settings.autocomplete && chatStore.showMentionAutocomplete && matchingChatters.isNotEmpty) ...[ - const Divider( - height: 1.0, - thickness: 1.0, - ), - SizedBox( - height: 50, - child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 5.0), - itemCount: matchingChatters.length, - scrollDirection: Axis.horizontal, - itemBuilder: (context, index) => Button( - color: Theme.of(context).colorScheme.secondary, - onPressed: () { - final split = chatStore.textController.text.split(' ') - ..removeLast() - ..add('@${matchingChatters[index]} '); + 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]), + chatStore.textController.text = split.join(' '); + chatStore.textController.selection = + TextSelection.fromPosition( + TextPosition( + offset: chatStore.textController.text.length, + ), + ); + }, + child: Text(matchingChatters[index]), + ), ), ), - ) + ), ], Padding( - padding: const EdgeInsets.fromLTRB(10.0, 10.0, 0.0, 10.0), + padding: const EdgeInsets.only(left: 12, top: 8, bottom: 8), child: Row( children: [ if (!chatStore.expandChat && chatStore.settings.chatWidth < 0.3 && chatStore.settings.showVideo && - MediaQuery.of(context).orientation == - Orientation.landscape) - IconButton( - tooltip: 'Enter a message', - onPressed: () { - chatStore.expandChat = true; - chatStore.textFieldFocusNode.requestFocus(); + context.isLandscape) + Builder( + builder: (context) { + final isDisabled = !chatStore.auth.isLoggedIn; + + return GestureDetector( + onTap: isDisabled + ? () { + chatStore.updateNotification( + loginTooltipMessage, + ); + } + : null, + child: IconButton( + tooltip: 'Enter a message', + onPressed: isDisabled + ? null + : () { + chatStore.expandChat = true; + chatStore.safeRequestFocus(); + }, + icon: const Icon(Icons.edit), + ), + ); }, - icon: const Icon(Icons.edit), ) else Expanded( - child: TextField( - textInputAction: TextInputAction.send, - focusNode: chatStore.textFieldFocusNode, - minLines: 1, - maxLines: 5, - enabled: chatStore.auth.isLoggedIn ? true : false, - decoration: InputDecoration( - contentPadding: const EdgeInsets.only(left: 15.0), - prefixIcon: chatStore.settings.emoteMenuButtonOnLeft - ? emoteMenuButton - : null, - suffixIcon: chatStore.settings.emoteMenuButtonOnLeft - ? null - : emoteMenuButton, - hintMaxLines: 1, - hintText: chatStore.auth.isLoggedIn - ? 'Send a message ${chatStore.settings.chatDelay == 0 ? '' : '(${chatStore.settings.chatDelay.toInt()}s delay)'}' - : 'Log in to chat', - ), - controller: chatStore.textController, - onSubmitted: chatStore.sendMessage, - ), - ), - if (chatStore.showSendButton && - (chatStore.settings.chatWidth >= 0.3 || - chatStore.expandChat || - MediaQuery.of(context).orientation == - Orientation.portrait)) - IconButton( - tooltip: 'Send', - icon: const Icon(Icons.send_rounded), - onPressed: chatStore.auth.isLoggedIn - ? () => chatStore - .sendMessage(chatStore.textController.text) - : null, - ) - else - IconButton( - icon: Icon(Icons.adaptive.more_rounded), - tooltip: 'More', - onPressed: () => showModalBottomSheet( - backgroundColor: Colors.transparent, - isScrollControlled: true, - context: context, - builder: (_) => ChatDetails( - chatDetailsStore: chatStore.chatDetailsStore, - chatStore: chatStore, - userLogin: chatStore.channelName, - ), + 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; + + return GestureDetector( + onTap: !isLoggedIn + ? () { + chatStore.updateNotification( + loginTooltipMessage, + ); + } + : null, + child: ExtendedTextField( + 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, + ), + decoration: InputDecoration( + prefixIcon: + chatStore.settings.emoteMenuButtonOnLeft + ? emoteMenuButton + : null, + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + if (!chatStore + .settings + .emoteMenuButtonOnLeft && + emoteMenuButton != null) + emoteMenuButton, + ], + ), + 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', + ), + controller: chatStore.textController, + onSubmitted: chatStore.sendMessage, + onTapOutside: (_) { + chatStore.textFieldFocusNode.unfocus(); + }, + ), + ); + }, ), ), + 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, + ), + ), + ), + ), ], ), ), ], ); + + // 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, + ), + child: measuredBar, + ) + : BlurredContainer( + gradientDirection: GradientDirection.down, + padding: EdgeInsets.only( + bottom: needsBottomPadding + ? MediaQuery.of(context).padding.bottom + : 0, + ), + child: measuredBar, + ); }, ); } diff --git a/lib/screens/channel/chat/widgets/chat_message.dart b/lib/screens/channel/chat/widgets/chat_message.dart index 0ee4b72c..339254dc 100644 --- a/lib/screens/channel/chat/widgets/chat_message.dart +++ b/lib/screens/channel/chat/widgets/chat_message.dart @@ -1,84 +1,270 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; 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'; 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? 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({ - Key? key, + super.key, required this.ircMessage, required this.chatStore, this.isModal = false, - }) : super(key: key); + this.forceTimestamp = false, + this.showReplyHeader = true, + this.isInReplyThread = false, + this.overrideChannelIdToUserTwitch, + this.overrideCurrentChannelId, + this.inputChatStore, + this.onActivateSourceTab, + }); + + void onTapName(BuildContext context) { + // Ignore if the message is a recent message in the modal bottom sheet. + if (isModal) return; + + // Ignore if long-pressing own username. + if (ircMessage.user == null || + ircMessage.user == chatStore.auth.user.details?.login) { + return; + } + + showModalBottomSheetWithProperFocus( + isScrollControlled: true, + context: context, + builder: (context) => ChatUserModal( + chatStore: chatStore, + username: ircMessage.user!, + userId: ircMessage.tags['user-id']!, + displayName: ircMessage.tags['display-name']!, + onActivateSourceTab: onActivateSourceTab, + ), + ); + } + + void onTapPingedUser(BuildContext context, {required String nickname}) { + // Ignore if the message is a recent message in the modal bottom sheet. + if (isModal) return; + + final twitchApi = context.read(); + twitchApi.getUser(userLogin: nickname).then((user) { + if (context.mounted) { + showModalBottomSheetWithProperFocus( + isScrollControlled: true, + context: context, + builder: (context) => ChatUserModal( + chatStore: chatStore, + 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 copyMessage() async { + await Clipboard.setData(ClipboardData(text: ircMessage.message ?? '')); + + _effectiveInputStore.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) { + copyMessage(); + return; + } + + showModalBottomSheetWithProperFocus( + context: context, + isScrollControlled: true, + builder: (context) => ListView( + shrinkWrap: true, + primary: false, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text.rich( + TextSpan( + children: ircMessage.generateSpan( + context, + assetsStore: chatStore.assetsStore, + emoteScale: chatStore.settings.emoteScale, + 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, + ), + style: defaultTextStyle, + ), + ), + ), + const Divider(indent: 12, endIndent: 12), + ListTile( + onTap: () { + copyMessage(); + Navigator.pop(context); + }, + leading: const Icon(Icons.copy), + title: const Text('Copy message'), + ), + ListTile( + onTap: () async { + await copyMessage(); + + final effectiveChatDelay = chatStore.settings.effectiveChatDelay; + + final hasChatDelay = + chatStore.settings.showVideo && effectiveChatDelay > 0; + + if (hasChatDelay) { + _effectiveInputStore.updateNotification( + 'Chatting is disabled due to message delay (${effectiveChatDelay.toInt()}s)', + ); + if (context.mounted) { + Navigator.pop(context); + } + return; + } + + // In merged mode, switch to the source tab so the paste + // targets the correct channel's composer. + onActivateSourceTab?.call(); + + // Paste the copied message into the text controller + chatStore.textController.text = ircMessage.message ?? ''; + chatStore.safeRequestFocus(); + if (context.mounted) { + Navigator.pop(context); + } + }, + leading: const Icon(Icons.content_paste), + title: const Text('Copy message and paste'), + ), + ListTile( + onTap: () { + // In merged mode, switch to the source tab so the reply + // targets the correct channel (reply-parent-msg-id is + // channel-scoped). + onActivateSourceTab?.call(); + + chatStore.replyingToMessage = ircMessage; + chatStore.safeRequestFocus(); + Navigator.pop(context); + }, + leading: const Icon(Icons.reply), + title: const Text('Reply to message'), + ), + if (ircMessage.tags['user-id'] != null) + ModerationActions( + chatStore: chatStore, + targetUserId: ircMessage.tags['user-id']!, + targetName: + ircMessage.tags['display-name'] ?? ircMessage.user ?? '', + messageId: ircMessage.tags['id'], + ), + ], + ), + ); + } @override Widget build(BuildContext context) { - void onTapName() { - // Ignore if the message is a recent message in the modal bottom sheet. - if (isModal) return; - - // Ignore if long-pressing own username. - if (ircMessage.user == null || - ircMessage.user == chatStore.auth.user.details?.login) return; - - showModalBottomSheet( - backgroundColor: Colors.transparent, - isScrollControlled: true, - context: context, - builder: (context) => ChatUserModal( - chatStore: chatStore, - username: ircMessage.user!, - userId: ircMessage.tags['user-id']!, - displayName: ircMessage.tags['display-name']!, - ), - ); - } - - Future onLongPressMessage() async { - HapticFeedback.lightImpact(); - - await Clipboard.setData(ClipboardData(text: ircMessage.message)); - - chatStore.updateNotification('Message copied'); - } - final defaultTextStyle = DefaultTextStyle.of(context).style; + final messageHeaderIconSize = + defaultBadgeSize * chatStore.settings.badgeScale; + final messageHeaderTextColor = defaultTextStyle.color?.withValues( + alpha: 0.5, + ); + const messageHeaderFontWeight = FontWeight.w600; return Observer( builder: (context) { - Color? color; + Color? highlightColor; final Widget renderMessage; switch (ircMessage.command) { case Command.privateMessage: case Command.userState: - // If user is being mentioned in the message, highlight it red. - if (ircMessage.mention == true) color = Colors.red.withOpacity(0.2); - if (chatStore.settings.highlightFirstTimeChatter && - ircMessage.tags['first-msg'] == '1') { - color = Colors.green.withOpacity(0.2); - } + final shouldHighlightFirstMessage = + chatStore.settings.highlightFirstTimeChatter && + ircMessage.tags['first-msg'] == '1'; + final shouldHighlightMessage = + chatStore.settings.showUserNotices && + ircMessage.tags['msg-id'] == 'highlighted-message'; final messageSpan = Text.rich( TextSpan( children: ircMessage.generateSpan( - onTapName: onTapName, + context, + onTapName: () => onTapName(context), + onTapPingedUser: (nickname) => + onTapPingedUser(context, nickname: nickname), style: defaultTextStyle, assetsStore: chatStore.assetsStore, emoteScale: chatStore.settings.emoteScale, badgeScale: chatStore.settings.badgeScale, - useReadableColors: chatStore.settings.useReadableColors, - isLightTheme: - Theme.of(context).brightness == Brightness.light, launchExternal: chatStore.settings.launchUrlExternal, timestamp: chatStore.settings.timestampType, + forceTimestamp: forceTimestamp, + showHistoricalTimestamps: + chatStore.settings.showHistoricalTimestamps, + channelIdToUserTwitch: + overrideChannelIdToUserTwitch ?? + chatStore.assetsStore.channelIdToUserTwitch, + currentChannelId: + overrideCurrentChannelId ?? chatStore.channelId, ), ), ); @@ -87,52 +273,91 @@ class ChatMessage extends StatelessWidget { final replyUser = ircMessage.tags['reply-parent-display-name']; final replyBody = ircMessage.tags['reply-parent-msg-body']; - if ((replyUser != null && replyBody != null) || - (chatStore.settings.highlightFirstTimeChatter && - ircMessage.tags['first-msg'] == '1')) { - renderMessage = Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - replyUser != null && replyBody != null - ? Icons.reply_rounded - : Icons.new_releases_outlined, - size: defaultBadgeSize * chatStore.settings.badgeScale, - color: defaultTextStyle.color?.withOpacity(0.5), - textDirection: TextDirection.rtl, + Widget? messageHeaderIcon; + Widget? messageHeader; + if (replyUser != null && replyBody != null && showReplyHeader) { + messageHeaderIcon = Icon( + Icons.chat_rounded, + size: messageHeaderIconSize, + color: messageHeaderTextColor, + ); + messageHeader = GestureDetector( + onTap: isModal + ? null + : () => showModalBottomSheetWithProperFocus( + context: context, + builder: (context) { + return ReplyThread( + selectedMessage: ircMessage, + chatStore: chatStore, + ); + }, ), - const SizedBox(width: 5.0), - Flexible( - child: replyUser != null && replyBody != null - ? Tooltip( - message: 'Replying to @$replyUser: $replyBody', - preferBelow: false, - triggerMode: TooltipTriggerMode.tap, - showDuration: const Duration(seconds: 5), - child: Text( - 'Replying to @$replyUser: $replyBody', - maxLines: 1, - style: TextStyle( - overflow: TextOverflow.ellipsis, - color: defaultTextStyle.color - ?.withOpacity(0.5), - ), - ), - ) - : Text( - 'First time chatting', - style: TextStyle( - fontWeight: FontWeight.w600, - color: - defaultTextStyle.color?.withOpacity(0.5), - ), - ), + child: Text.rich( + maxLines: 1, + overflow: TextOverflow.ellipsis, + TextSpan( + children: [ + TextSpan( + text: replyUser, + style: TextStyle( + fontWeight: FontWeight.w600, + color: messageHeaderTextColor, + ), + ), + TextSpan( + text: ': $replyBody', + style: TextStyle(color: messageHeaderTextColor), ), ], ), - const SizedBox(height: 5.0), + ), + ); + } else if (shouldHighlightFirstMessage) { + highlightColor = context.frostyColors.highlightedMessage; + messageHeaderIcon = Icon( + Icons.auto_awesome_rounded, + size: messageHeaderIconSize, + color: messageHeaderTextColor, + ); + messageHeader = Text( + 'First message', + style: TextStyle( + fontWeight: messageHeaderFontWeight, + color: messageHeaderTextColor, + ), + ); + } else if (shouldHighlightMessage) { + highlightColor = context.frostyColors.highlightedMessage; + messageHeader = Text( + 'Highlighted message', + style: TextStyle( + fontWeight: messageHeaderFontWeight, + color: messageHeaderTextColor, + ), + ); + } + + // If user is being mentioned in the message, highlight it red. + if (ircMessage.mention == true) { + highlightColor = context.colorScheme.error; + } + + if (messageHeader != null) { + renderMessage = Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + if (messageHeaderIcon != null) + Row( + spacing: 4, + children: [ + messageHeaderIcon, + Flexible(child: messageHeader), + ], + ) + else + messageHeader, messageSpan, ], ); @@ -147,41 +372,58 @@ class ChatMessage extends StatelessWidget { final banDuration = ircMessage.tags['ban-duration']; renderMessage = Opacity( - opacity: 0.4, + opacity: 0.5, child: Column( crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, children: [ if (banDuration == null) if (ircMessage.command == Command.clearMessage) const Text( 'Message deleted', - style: TextStyle(fontWeight: FontWeight.w600), + style: TextStyle(fontWeight: messageHeaderFontWeight), ) else const Text( 'Permanently banned', - style: TextStyle(fontWeight: FontWeight.w600), + style: TextStyle(fontWeight: messageHeaderFontWeight), ) else Text( 'Timed out for $banDuration ${int.parse(banDuration) > 1 ? 'seconds' : 'second'}', - style: const TextStyle(fontWeight: FontWeight.w600), + style: const TextStyle( + fontWeight: messageHeaderFontWeight, + ), ), - const SizedBox(height: 5.0), Text.rich( TextSpan( children: ircMessage.generateSpan( - onTapName: onTapName, + context, + onTapName: () => onTapName(context), style: defaultTextStyle, assetsStore: chatStore.assetsStore, emoteScale: chatStore.settings.emoteScale, badgeScale: chatStore.settings.badgeScale, - showMessage: chatStore.settings.showDeletedMessages, - useReadableColors: chatStore.settings.useReadableColors, - isLightTheme: - Theme.of(context).brightness == Brightness.light, + showMessage: + chatStore.settings.showDeletedMessages || + chatStore.revealedMessageIds.contains( + ircMessage.tags['id'], + ), + onTapDeletedMessage: () { + if (ircMessage.tags['id'] != null) { + chatStore.revealMessage(ircMessage.tags['id']!); + } + }, launchExternal: chatStore.settings.launchUrlExternal, timestamp: chatStore.settings.timestampType, + forceTimestamp: forceTimestamp, + showHistoricalTimestamps: + chatStore.settings.showHistoricalTimestamps, + channelIdToUserTwitch: + overrideChannelIdToUserTwitch ?? + chatStore.assetsStore.channelIdToUserTwitch, + currentChannelId: + overrideCurrentChannelId ?? chatStore.channelId, ), ), ), @@ -190,59 +432,134 @@ class ChatMessage extends StatelessWidget { ); break; case Command.notice: - renderMessage = Text.rich( - TextSpan(text: ircMessage.message), - style: TextStyle(color: defaultTextStyle.color?.withOpacity(0.5)), + // 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( + TextSpan( + children: [ + TextSpan( + text: ircMessage.message ?? '', + style: noticeStyle, + ), + TextSpan(text: ' '), + TextSpan( + text: ircMessage.actionLabel!, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w600, + decoration: TextDecoration.underline, + decorationColor: Theme.of(context).colorScheme.primary, + ), + recognizer: TapGestureRecognizer() + ..onTap = ircMessage.actionCallback, + ), + ], + ), + ); + } else { + renderMessage = Text.rich( + TextSpan(text: ircMessage.message), + style: noticeStyle, + ); + } break; case Command.userNotice: if (chatStore.settings.showUserNotices) { - color = Colors.deepPurple.withOpacity(0.2); + highlightColor = context.frostyColors.highlightedMessage; + Widget? messageHeaderIcon; + Widget? messageHeader; + + if (ircMessage.tags.containsKey('system-msg')) { + final messageId = ircMessage.tags['msg-id']; + final isGift = messageId?.contains('gift') == true; + final isSub = messageId?.contains('sub') == true; + final isRaid = messageId?.contains('raid') == true; + final isPrime = + ircMessage.tags['msg-param-sub-plan'] == 'Prime'; + + IconData? icon; + if (isGift) { + icon = Icons.card_giftcard_rounded; + } else if (isSub) { + icon = Icons.star_rounded; + } else if (isRaid) { + icon = Icons.people_rounded; + } else if (isPrime) { + icon = Icons.workspace_premium_rounded; + } + + if (icon != null) { + messageHeaderIcon = Icon( + icon, + size: messageHeaderIconSize, + color: messageHeaderTextColor, + ); + } + + messageHeader = Text( + ircMessage.tags['system-msg']!, + style: TextStyle( + fontWeight: messageHeaderFontWeight, + color: messageHeaderTextColor, + ), + ); + } else if (ircMessage.tags['msg-id'] == 'announcement') { + messageHeaderIcon = Icon( + Icons.campaign_rounded, + size: messageHeaderIconSize, + color: messageHeaderTextColor, + ); + messageHeader = Text( + 'Announcement', + style: TextStyle( + fontWeight: messageHeaderFontWeight, + color: messageHeaderTextColor, + ), + ); + } renderMessage = Column( crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, children: [ - if (ircMessage.tags.containsKey('system-msg')) - Text( - ircMessage.tags['system-msg']!, - style: TextStyle( - fontWeight: FontWeight.w600, - color: defaultTextStyle.color?.withOpacity(0.5)), - ), - if (ircMessage.tags['msg-id'] == 'announcement') + if (messageHeader != null) Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, children: [ - Icon( - Icons.announcement_outlined, - size: - defaultBadgeSize * chatStore.settings.badgeScale, - ), - const SizedBox(width: 5.0), - const Text( - 'Announcement', - style: TextStyle(fontWeight: FontWeight.w600), - ), + if (messageHeaderIcon != null) ...[messageHeaderIcon], + Expanded(child: messageHeader), ], ), - const SizedBox(height: 5.0), - if (ircMessage.message != null) + if (ircMessage.message != null) ...[ Text.rich( TextSpan( children: ircMessage.generateSpan( - onTapName: onTapName, + context, + onTapName: () => onTapName(context), style: defaultTextStyle, assetsStore: chatStore.assetsStore, emoteScale: chatStore.settings.emoteScale, badgeScale: chatStore.settings.badgeScale, - useReadableColors: - chatStore.settings.useReadableColors, - isLightTheme: - Theme.of(context).brightness == Brightness.light, launchExternal: chatStore.settings.launchUrlExternal, timestamp: chatStore.settings.timestampType, + forceTimestamp: forceTimestamp, + showHistoricalTimestamps: + chatStore.settings.showHistoricalTimestamps, + channelIdToUserTwitch: + overrideChannelIdToUserTwitch ?? + chatStore.assetsStore.channelIdToUserTwitch, + currentChannelId: + overrideCurrentChannelId ?? chatStore.channelId, ), ), ), + ], ], ); } else { @@ -253,42 +570,83 @@ class ChatMessage extends StatelessWidget { renderMessage = const SizedBox(); } + // Check if this is a reply message in reply thread context for indentation + final isReplyInThread = + isInReplyThread && + ircMessage.tags['reply-parent-display-name'] != null && + ircMessage.tags['reply-parent-msg-body'] != null; + + // Add reply icon for messages in reply thread + final messageWithIcon = isReplyInThread + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(right: 8.0, top: 2.0), + child: Icon( + Icons.subdirectory_arrow_right_rounded, + size: 16, + color: messageHeaderTextColor, + ), + ), + Expanded(child: renderMessage), + ], + ) + : renderMessage; + final paddedMessage = Padding( - padding: EdgeInsets.symmetric( - vertical: chatStore.settings.messageSpacing / 2, - horizontal: 10.0), - child: renderMessage, + padding: EdgeInsets.only( + top: chatStore.settings.messageSpacing / 2, + bottom: chatStore.settings.messageSpacing / 2, + left: isReplyInThread ? 16 : (highlightColor == null ? 12 : 0), + right: highlightColor == null ? 12 : 0, + ), + child: messageWithIcon, ); // Add a divider above the message if dividers are enabled. final dividedMessage = chatStore.settings.showChatMessageDividers ? Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ - paddedMessage, - const Divider( - height: 1.0, - thickness: 1.0, - ), - ], + children: [paddedMessage, const Divider()], ) : paddedMessage; // Color the message if the color has been set. - final coloredMessage = color == null + final coloredMessage = highlightColor == null ? dividedMessage - : ColoredBox(color: color, child: dividedMessage); + : Container( + padding: const EdgeInsets.only(left: 8, right: 12), + decoration: BoxDecoration( + color: highlightColor.withValues(alpha: 0.1), + border: Border( + left: BorderSide(color: highlightColor, width: 4), + ), + ), + child: dividedMessage, + ); - return InkWell( + 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 (chatStore.assetsStore.showEmoteMenu) { - chatStore.assetsStore.showEmoteMenu = false; + if (_effectiveInputStore.assetsStore.showEmoteMenu) { + _effectiveInputStore.assetsStore.showEmoteMenu = false; } }, - onLongPress: onLongPressMessage, - child: coloredMessage, + onLongPress: () => onLongPressMessage(context, defaultTextStyle), + child: fadedMessage, ); + + return finalMessage; }, ); } diff --git a/lib/screens/channel/chat/widgets/chat_tabs.dart b/lib/screens/channel/chat/widgets/chat_tabs.dart new file mode 100644 index 00000000..490ce0df --- /dev/null +++ b/lib/screens/channel/chat/widgets/chat_tabs.dart @@ -0,0 +1,542 @@ +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/profile_picture.dart'; + +/// Widget that displays multiple chat tabs with a tab bar. +/// Wraps the existing Chat widget and manages tab switching. +class ChatTabs extends StatelessWidget { + final ChatTabsStore chatTabsStore; + final EdgeInsetsGeometry? listPadding; + + const ChatTabs({super.key, required this.chatTabsStore, this.listPadding}); + + Future _handleAddChat(BuildContext context) async { + if (!chatTabsStore.canAddTab) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Maximum 10 chats open'), + duration: Duration(seconds: 2), + ), + ); + return; + } + + final result = await AddChatSheet.show(context, chatTabsStore.twitchApi); + + if (result != null) { + final added = chatTabsStore.addTab( + channelId: result.channelId, + channelLogin: result.channelLogin, + displayName: result.displayName, + ); + + if (!added && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Channel already open, switched to it'), + duration: Duration(seconds: 2), + ), + ); + } + } + } + + /// 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 >[]; + final showTabBar = + chatTabsStore.showTabBar || sharedChatParticipants.isNotEmpty; + final showMerge = tabs.where((t) => t.isActivated).length >= 2; + + final tabBarHeight = showTabBar ? 48.0 : 0.0; + final topInset = listPadding?.resolve(TextDirection.ltr).top ?? 0; + final adjustedPadding = listPadding != null + ? listPadding!.add(EdgeInsets.only(top: tabBarHeight)) + : EdgeInsets.only(top: tabBarHeight); + + return PopScope( + canPop: Platform.isIOS, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + + // Android back gesture: dismiss overlays before navigating away. + final activeStore = chatTabsStore.activeChatStore; + if (activeStore.assetsStore.showEmoteMenu) { + activeStore.assetsStore.showEmoteMenu = false; + return; + } + if (activeStore.textFieldFocusNode.hasFocus) { + activeStore.unfocusInput(); + return; + } + Navigator.of(context).pop(); + }, + child: Stack( + children: [ + 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(), + ), + ), + if (showTabBar) + Positioned( + top: topInset, + left: 0, + 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, + ), + ); + }, + ), + ), + if (showMerge) + Positioned( + right: 12, + top: 0, + bottom: 0, + child: Center( + child: _buildMoreActionsMenu(context), + ), + ), + ], + ), + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildMoreActionsMenu(BuildContext context) { + final settings = chatTabsStore.activeChatStore.settings; + + return _AnchoredPopupMenu( + itemsBuilder: (close) => [ + Observer( + builder: (_) => CheckboxMenuButton( + value: chatTabsStore.mergedMode, + onChanged: (_) { + HapticFeedback.selectionClick(); + chatTabsStore.toggleMergedMode(); + }, + child: const Text('Merge chats'), + ), + ), + Observer( + builder: (_) => CheckboxMenuButton( + value: settings.focusCurrentChannel, + onChanged: (newValue) { + HapticFeedback.selectionClick(); + settings.focusCurrentChannel = newValue ?? false; + }, + child: const Text('Focus current channel'), + ), + ), + ], + anchorBuilder: (context, toggle) { + return IconButton.filledTonal( + icon: const Icon(Icons.more_vert, size: 18), + tooltip: 'Chat options', + visualDensity: VisualDensity.compact, + style: IconButton.styleFrom( + minimumSize: const Size(42, 42), + backgroundColor: + Theme.of(context).colorScheme.surfaceContainerHighest, + ), + onPressed: () { + HapticFeedback.selectionClick(); + toggle(); + }, + ); + }, + ); + } + + Widget _buildTab(BuildContext context, int index) { + final tabInfo = chatTabsStore.tabs[index]; + final isActive = index == chatTabsStore.activeTabIndex; + final isSecondary = !tabInfo.isPrimary; + 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); + + InputChip buildChip({VoidCallback? onDeleted}) => InputChip( + avatar: Badge( + smallSize: 8, + backgroundColor: theme.colorScheme.primary, + isLabelVisible: hasUnread, + child: Opacity( + opacity: dimmed ? 0.5 : 1.0, + child: ProfilePicture( + userLogin: tabInfo.channelLogin, + radius: 12, + ), + ), + ), + label: Text( + displayName, + style: dimmed + ? TextStyle( + color: theme.textTheme.bodyMedium?.color + ?.withValues(alpha: 0.5), + ) + : null, + ), + selected: isActive, + showCheckmark: false, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onPressed: () { + if (isActive) return; + HapticFeedback.selectionClick(); + chatTabsStore.setActiveTab(index); + }, + onDeleted: onDeleted, + deleteButtonTooltipMessage: + onDeleted != null ? 'Tab options' : null, + ); + + if (!isSecondary) return buildChip(); + + return _AnchoredPopupMenu( + itemsBuilder: (close) => [ + if (isActivated) + MenuItemButton( + leadingIcon: const Icon(Icons.power_off_rounded, size: 18), + child: const Text('Disconnect'), + onPressed: () { + HapticFeedback.lightImpact(); + close(); + chatTabsStore.deactivateTab(index); + }, + ), + MenuItemButton( + leadingIcon: Icon( + Icons.delete_outline_rounded, + size: 18, + color: theme.colorScheme.error, + ), + child: Text( + 'Remove', + style: TextStyle(color: theme.colorScheme.error), + ), + onPressed: () { + HapticFeedback.lightImpact(); + close(); + chatTabsStore.removeTab(index); + }, + ), + ], + anchorBuilder: (context, toggle) => + buildChip(onDeleted: toggle), + ); + }, + ); + } + + /// Builds a bubble for a Twitch native shared chat participant channel. + /// Unlike manual tabs, these don't own a connection: tapping spotlights + /// the channel (unfades it, fading the rest — see [chat_message.dart]), + /// and the trailing menu hides/shows the channel's messages instead of + /// disconnecting/removing a tab. + Widget _buildSharedChatBubble( + BuildContext context, + String channelId, + UserTwitch user, + ) { + final activeStore = chatTabsStore.activeChatStore; + final displayName = getReadableName(user.displayName, user.login); + + return Observer( + builder: (context) { + final theme = Theme.of(context); + final isFocused = activeStore.sharedChatBubbles.isFocused(channelId); + final isHidden = activeStore.sharedChatBubbles.isHidden(channelId); + + InputChip buildChip({VoidCallback? onDeleted}) => InputChip( + avatar: Opacity( + opacity: isHidden ? 0.5 : 1.0, + child: ProfilePicture(userLogin: user.login, radius: 12), + ), + label: Text( + displayName, + style: isHidden + ? TextStyle( + color: theme.textTheme.bodyMedium?.color + ?.withValues(alpha: 0.5), + ) + : null, + ), + selected: isFocused, + showCheckmark: false, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onPressed: () { + HapticFeedback.selectionClick(); + activeStore.sharedChatBubbles.toggleFocus(channelId); + }, + onDeleted: onDeleted, + deleteButtonTooltipMessage: + onDeleted != null ? 'Channel options' : null, + ); + + return _AnchoredPopupMenu( + itemsBuilder: (close) => [ + MenuItemButton( + leadingIcon: Icon( + isHidden + ? Icons.visibility_rounded + : Icons.visibility_off_rounded, + size: 18, + ), + child: Text(isHidden ? 'Show channel' : 'Hide channel'), + onPressed: () { + HapticFeedback.lightImpact(); + close(); + if (isHidden) { + activeStore.sharedChatBubbles.show(channelId); + } else { + activeStore.sharedChatBubbles.hide(channelId); + } + }, + ), + ], + anchorBuilder: (context, toggle) => buildChip(onDeleted: toggle), + ); + }, + ); + } +} + +/// Lightweight popup menu anchored to a child widget. The menu's top-right +/// aligns to the anchor's bottom-right via [CompositedTransformFollower] and +/// fades + scales in from that corner. [MenuAnchor] doesn't expose an entry +/// animation hook in this Flutter version, so we drive one ourselves. +class _AnchoredPopupMenu extends StatefulWidget { + final Widget Function(BuildContext context, VoidCallback toggle) + anchorBuilder; + final List 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(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), + ), + ); + } +} diff --git a/lib/screens/channel/chat/widgets/chat_user_modal.dart b/lib/screens/channel/chat/widgets/chat_user_modal.dart index ae7765ca..6cb2e55a 100644 --- a/lib/screens/channel/chat/widgets/chat_user_modal.dart +++ b/lib/screens/channel/chat/widgets/chat_user_modal.dart @@ -1,28 +1,30 @@ import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:frosty/constants.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'; -import 'package:frosty/widgets/block_report_modal.dart'; -import 'package:frosty/widgets/bottom_sheet.dart'; -import 'package:frosty/widgets/button.dart'; +import 'package:frosty/widgets/frosty_scrollbar.dart'; import 'package:frosty/widgets/profile_picture.dart'; -import 'package:frosty/widgets/section_header.dart'; +import 'package:frosty/widgets/user_actions_modal.dart'; class ChatUserModal extends StatefulWidget { final ChatStore chatStore; final String username; final String displayName; final String userId; + final VoidCallback? onActivateSourceTab; const ChatUserModal({ - Key? key, + super.key, required this.chatStore, required this.username, required this.displayName, required this.userId, - }) : super(key: key); + this.onActivateSourceTab, + }); @override State createState() => _ChatUserModalState(); @@ -31,96 +33,109 @@ class ChatUserModal extends StatefulWidget { class _ChatUserModalState extends State { @override Widget build(BuildContext context) { - final name = regexEnglish.hasMatch(widget.displayName) - ? widget.displayName - : '${widget.displayName} (${widget.username})'; + final name = getReadableName(widget.displayName, widget.username); - return FrostyBottomSheet( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.5, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ListTile( - contentPadding: const EdgeInsets.all(10.0), - leading: ProfilePicture( - userLogin: widget.username, - ), - title: Row( - children: [ - Flexible( - child: Tooltip( - message: name, - child: Text( - name, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontWeight: FontWeight.bold), + return SizedBox( + height: MediaQuery.of(context).size.height * 0.5, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + contentPadding: const EdgeInsets.fromLTRB(12, 0, 12, 0), + leading: ProfilePicture(userLogin: widget.username), + title: Row( + children: [ + Flexible( + child: Tooltip( + message: name, + child: Text( + name, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.chatStore.auth.isLoggedIn) + IconButton( + tooltip: 'Reply', + onPressed: () { + widget.onActivateSourceTab?.call(); + widget.chatStore.textController.text = + '@${widget.username} '; + Navigator.pop(context); + widget.chatStore.safeRequestFocus(); + }, + icon: const Icon(Icons.reply_rounded), + ), + IconButton( + tooltip: 'More', + onPressed: () => showModalBottomSheetWithProperFocus( + context: context, + builder: (context) => UserActionsModal( + authStore: widget.chatStore.auth, + 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, ), ), ), - ], - ), - trailing: widget.chatStore.auth.isLoggedIn - ? Button( - padding: const EdgeInsets.symmetric(horizontal: 15.0), - onPressed: () { - widget.chatStore.textController.text = - '@${widget.username} '; - Navigator.pop(context); - widget.chatStore.textFieldFocusNode.requestFocus(); - }, - child: const Text('Reply'), - ) - : null, - onTap: () => showModalBottomSheet( - backgroundColor: Colors.transparent, - context: context, - builder: (context) => BlockReportModal( - authStore: widget.chatStore.auth, - name: name, - userLogin: widget.username, - userId: widget.userId, + icon: Icon(Icons.adaptive.more_rounded), ), - ), + ], ), - const SectionHeader( - 'Recent messages', - padding: EdgeInsets.all(10.0), - ), - Expanded( - child: Observer( - builder: (context) { - final userMessages = widget.chatStore.messages.reversed - .where((message) => message.user == widget.username) - .toList(); + ), + const Divider(indent: 12, endIndent: 12), + Expanded( + child: Observer( + builder: (context) { + final userMessages = widget.chatStore.messages.reversed + .where((message) => message.user == widget.username) + .toList(); - if (userMessages.isEmpty) { - return const AlertMessage(message: 'No recent messages'); - } + if (userMessages.isEmpty) { + return const AlertMessage(message: 'No recent messages'); + } - return MediaQuery( - data: MediaQuery.of(context).copyWith( - textScaleFactor: - widget.chatStore.settings.messageScale), - child: DefaultTextStyle( - style: DefaultTextStyle.of(context).style.copyWith( - fontSize: widget.chatStore.settings.fontSize), + return MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear( + widget.chatStore.settings.messageScale, + ), + ), + child: DefaultTextStyle( + style: DefaultTextStyle.of(context).style.copyWith( + fontSize: widget.chatStore.settings.fontSize, + ), + child: FrostyScrollbar( child: ListView.builder( reverse: true, + primary: false, itemCount: userMessages.length, itemBuilder: (context, index) => ChatMessage( ircMessage: userMessages[index], chatStore: widget.chatStore, isModal: true, + forceTimestamp: true, ), ), ), - ); - }, - ), - ) - ], - ), + ), + ); + }, + ), + ), + ], ), ); } diff --git a/lib/screens/channel/chat/widgets/moderation_actions.dart b/lib/screens/channel/chat/widgets/moderation_actions.dart new file mode 100644 index 00000000..390100c6 --- /dev/null +++ b/lib/screens/channel/chat/widgets/moderation_actions.dart @@ -0,0 +1,212 @@ +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 _run( + BuildContext context, + Future Function() action, + ) async { + Navigator.pop(context); + try { + await action(); + } on ApiException catch (e) { + chatStore.updateNotification(e.message); + } + } + + Future _delete() async { + final success = await chatStore.auth.user.deleteMessage( + broadcasterId: chatStore.channelId, + messageId: messageId!, + ); + chatStore.updateNotification( + success ? 'Message deleted' : 'Failed to delete message', + ); + } + + Future _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 _ban() async { + final success = await chatStore.auth.user.banOrTimeoutUser( + broadcasterId: chatStore.channelId, + userIdToBan: targetUserId, + ); + chatStore.updateNotification( + success ? '$targetName banned.' : 'Failed to ban user', + ); + } + + Future _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', + ); + } +} diff --git a/lib/screens/channel/chat/widgets/reply_thread.dart b/lib/screens/channel/chat/widgets/reply_thread.dart new file mode 100644 index 00000000..7dc83394 --- /dev/null +++ b/lib/screens/channel/chat/widgets/reply_thread.dart @@ -0,0 +1,98 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/models/irc.dart'; +import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; +import 'package:frosty/screens/channel/chat/widgets/chat_message.dart'; +import 'package:frosty/utils.dart'; +import 'package:frosty/widgets/frosty_scrollbar.dart'; +import 'package:frosty/widgets/section_header.dart'; + +class ReplyThread extends StatelessWidget { + final IRCMessage selectedMessage; + final ChatStore chatStore; + + const ReplyThread({ + super.key, + required this.selectedMessage, + required this.chatStore, + }); + + @override + Widget build(BuildContext context) { + final replyParent = chatStore.messages.firstWhereOrNull( + (message) => + message.tags['id'] == selectedMessage.tags['reply-parent-msg-id'], + ); + + final replyDisplayName = selectedMessage.tags['reply-parent-display-name']; + final replyUserLogin = selectedMessage.tags['reply-parent-user-login']; + final replyBody = selectedMessage.tags['reply-parent-msg-body']; + + final replyName = replyUserLogin != null + ? getReadableName(replyDisplayName!, replyUserLogin) + : replyDisplayName; + + return SizedBox( + height: MediaQuery.of(context).size.height * 0.5, + child: Observer( + builder: (context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(chatStore.settings.messageScale), + ), + child: DefaultTextStyle( + style: DefaultTextStyle.of( + context, + ).style.copyWith(fontSize: chatStore.settings.fontSize), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + const SectionHeader( + 'Replies', + isFirst: true, + padding: EdgeInsets.fromLTRB(12, 0, 12, 8), + ), + if (replyParent != null) + ChatMessage(ircMessage: replyParent, chatStore: chatStore) + else + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: Text( + '$replyName: $replyBody', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + Flexible( + child: FrostyScrollbar( + child: ListView( + primary: false, + children: chatStore.messages + .where( + (message) => + message.tags['reply-parent-msg-id'] == + selectedMessage.tags['reply-parent-msg-id'], + ) + .map( + (message) => ChatMessage( + isModal: true, + showReplyHeader: false, + isInReplyThread: true, + ircMessage: message, + chatStore: chatStore, + ), + ) + .toList(), + ), + ), + ), + ], + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/screens/channel/video/channel_info_header.dart b/lib/screens/channel/video/channel_info_header.dart new file mode 100644 index 00000000..0cf77aa2 --- /dev/null +++ b/lib/screens/channel/video/channel_info_header.dart @@ -0,0 +1,63 @@ +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, + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/lib/screens/channel/video/chat_latency_sync.dart b/lib/screens/channel/video/chat_latency_sync.dart new file mode 100644 index 00000000..0b5ff544 --- /dev/null +++ b/lib/screens/channel/video/chat_latency_sync.dart @@ -0,0 +1,32 @@ +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; + } + } +} diff --git a/lib/screens/channel/video/native_video.dart b/lib/screens/channel/video/native_video.dart new file mode 100644 index 00000000..78c51f52 --- /dev/null +++ b/lib/screens/channel/video/native_video.dart @@ -0,0 +1,106 @@ +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, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/screens/channel/video/native_video_player_interface.dart b/lib/screens/channel/video/native_video_player_interface.dart new file mode 100644 index 00000000..a01e34ec --- /dev/null +++ b/lib/screens/channel/video/native_video_player_interface.dart @@ -0,0 +1,8 @@ +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; +} diff --git a/lib/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart new file mode 100644 index 00000000..31077620 --- /dev/null +++ b/lib/screens/channel/video/native_video_store.dart @@ -0,0 +1,1469 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:better_native_video_player/better_native_video_player.dart'; +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/apis/twitch_ad_solutions.dart'; +import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; +import 'package:frosty/constants.dart'; +import 'package:frosty/models/channel.dart'; +import 'package:frosty/models/playback_access_token.dart'; +import 'package:frosty/models/stream.dart'; +import 'package:frosty/screens/channel/video/chat_latency_sync.dart'; +import 'package:frosty/screens/channel/video/native_video_player_interface.dart'; +import 'package:frosty/screens/channel/video/playback_error_recovery.dart'; +import 'package:frosty/screens/channel/video/stream_info_poller.dart'; +import 'package:frosty/screens/channel/video/video_timing_constants.dart'; +import 'package:frosty/screens/settings/stores/auth_store.dart'; +import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:mobx/mobx.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:simple_pip_mode/simple_pip.dart'; + +part 'native_video_store.g.dart'; + +class NativeVideoStore = NativeVideoStoreBase with _$NativeVideoStore; + +abstract class NativeVideoStoreBase + with Store + implements NativeVideoPlayerInterface { + static int _nextId = 0; + static const _legacyWebViewInSettings = + 'the legacy WebView player in Settings'; + + @override + final String userLogin; + final String userId; + final String displayName; + final TwitchApi twitchApi; + final TwitchGqlApi twitchGqlApi; + final AuthStore authStore; + + @override + final SettingsStore settingsStore; + + @readonly + NativeVideoPlayerController? _controller; + + Timer? _overlayTimer; + Timer? _latencyTimer; + Timer? _stallRecoveryTimer; + Timer? _streamInfoTimer; + var _stallRecoveryAttempt = 0; + var _isStalled = false; + Timer? _initRetryTimer; + Timer? _healthyPlaybackTimer; + DateTime? _lastRefreshTime; + bool _overlayWasVisibleBeforePip = true; + var _userPaused = false; + + String? _profileImageUrl; + var _manualPipRequested = false; + var _lastPipWasAutomatic = false; + List _qualityObjects = []; + var _firstTimeSettingQuality = true; + int? _pendingQualityIndex; + var _disposed = false; + var _initializing = true; + var _initInFlight = false; + var _controllerInitialized = false; + var _totalRefreshAttempts = 0; + var _highLatencyCount = 0; + + /// Timestamp of the most recent transition into `_onPlaying`. A subsequent + /// stall that arrives inside [VideoTimingConstants.shortPlayWindow] counts + /// as a bounce; repeated bounces surface a tailored CDN-trouble error. + DateTime? _lastPlayingStart; + int _shortPlayBounceCount = 0; + var _isQualitySwitching = false; + Timer? _qualitySwitchGraceTimer; + + /// Set when an automatic refresh was suppressed because the user had + /// paused (e.g. the playback session expired mid-pause). Consumed by + /// [handlePausePlay] to refresh instead of playing a dead session. + var _pendingRefreshOnResume = false; + var _initGeneration = 0; + static final _bareResolutionRe = RegExp(r'^(\d+)p$'); + + final _pip = SimplePip(); + late final ChatLatencySync _chatLatencySync = + ChatLatencySync(settingsStore: settingsStore); + late final StreamInfoPoller _streamInfoPoller = StreamInfoPoller( + twitchApi: twitchApi, + userLogin: userLogin, + userId: userId, + ); + ReactionDisposer? _disposeAndroidAutoPipReaction; + ReactionDisposer? _disposeVideoModeReaction; + + /// Cached SimplePip.isAutoPipAvailable result. Populated by the Android + /// auto-PiP reaction on first run; reused by dispose to avoid a redundant + /// platform-channel call. + bool? _autoPipAvailable; + + StreamSubscription? _pipSub; + StreamSubscription>? _qualitiesSub; + StreamSubscription? _adBreakSub; + + /// True while a server-stitched ad break is playing (native player detects + /// twitch-stitched-ad dateranges). Latency readings and play/buffer + /// transitions are unreliable during ads, so recovery and chat sync are + /// suspended rather than burning refresh attempts on ad-induced noise. + var _isAdBreakActive = false; + + /// True after we swapped to a TwitchAdSolutions backup player-type URL. + var _usingAdBackup = false; + + /// Serializes ad-break URL swaps so overlapping adBreakStream events don't + /// race loadUrl calls. + var _adSwapInFlight = false; + + late final TwitchAdFreePlayback _adFreePlayback = + TwitchAdFreePlayback(twitchGqlApi); + + @readonly + var _loading = true; + + /// Whether ad blocking is actively avoiding/replacing an ad pod. + @readonly + var _blockingAds = false; + + /// Exposes [_blockingAds] via [VideoPlayerInterface.isBlockingAds] + /// (must live in the hand-written store — build_runner will strip a + /// hand-patched override from the `.g.dart`). + @override + bool get isBlockingAds => _blockingAds; + + @readonly + var _paused = true; + + @readonly + var _hasPlayedOnce = false; + + @readonly + var _overlayVisible = true; + + @readonly + StreamTwitch? _streamInfo; + + @readonly + Channel? _offlineChannelInfo; + + @readonly + var _availableStreamQualities = []; + + /// Qualities that exist for this stream but require a subscription + /// (parsed from the playback token's restricted_bitrates). + @readonly + var _restrictedStreamQualities = []; + + @readonly + var _streamQualityIndex = 0; + + @override + @computed + String get streamQuality => + _availableStreamQualities.elementAtOrNull(_streamQualityIndex) ?? 'Auto'; + + @readonly + String? _latency; + + @readonly + var _isInPipMode = false; + + @readonly + var _isAudioOnlyMode = false; + + @readonly + String? _error; + + NativeVideoStoreBase({ + required this.userLogin, + required this.userId, + required this.displayName, + required this.twitchApi, + required this.twitchGqlApi, + required this.authStore, + required this.settingsStore, + }) { + _chatLatencySync.reset(); + _scheduleOverlayHide(); + updateStreamInfo(); + _startStreamInfoTimer(); + + // Skip eager init in chat-only mode — `play()` is what activates the + // iOS audio session, so without it nothing is allocated. + if (settingsStore.showVideo) { + _controller = _createController(); + _initPlayer(); + } else { + _loading = false; + _initializing = false; + } + + // `pause()` alone leaves the audio session, PiP controller, and URL + // session alive on iOS, which blocks low-power sleep states. Full + // dispose is the only path that deactivates the audio session. + _disposeVideoModeReaction = reaction( + (_) => settingsStore.showVideo, + (showVideo) { + if (!showVideo) { + _disposeController(); + _resetPlayerStateForChatOnly(); + } else if (_controller == null) { + _initFreshController(); + } + }, + ); + + if (Platform.isAndroid) { + // Explicit reaction on showVideo only (vs autorun, which re-runs on any + // observable the closure happens to read). Caches the auto-PiP + // availability check — it doesn't change within a session. + _disposeAndroidAutoPipReaction = reaction( + (_) => settingsStore.showVideo, + (showVideo) async { + _autoPipAvailable ??= await SimplePip.isAutoPipAvailable; + if (_disposed || !_autoPipAvailable!) return; + if (showVideo) { + _pip.setAutoPipMode(); + } else { + _pip.setAutoPipMode(autoEnter: false); + } + }, + fireImmediately: true, + ); + } + } + + NativeVideoPlayerController _createController() { + final controller = NativeVideoPlayerController( + id: _nextId++, + autoPlay: true, + showNativeControls: false, + mediaInfo: NativeVideoPlayerMediaInfo( + title: displayName, + subtitle: 'Frosty — Twitch', + artworkUrl: _profileImageUrl, + ), + ); + controller.addActivityListener(_onPlayerActivity); + return controller; + } + + /// Pushes the channel's Now Playing metadata (lock screen / control center) + /// to the native player using the cached profile image. + void _applyNowPlayingMediaInfo() { + if (_disposed || _controller == null) return; + _controller!.setMediaInfo(NativeVideoPlayerMediaInfo( + title: displayName, + subtitle: _streamInfo?.title ?? 'Twitch', + artworkUrl: _profileImageUrl, + )); + } + + @action + Future _initPlayer() async { + if (_initInFlight) return; + _initInFlight = true; + final generation = _initGeneration; + try { + // Fetch profile image URL for Now Playing artwork (fire-and-forget). + // _initPlayer re-runs on every hard refresh and chat-only toggle, so + // skip the Helix call once the URL is cached. + if (_profileImageUrl != null) { + _applyNowPlayingMediaInfo(); + } else { + twitchApi.getUser(id: userId).then((user) { + _profileImageUrl = user.profileImageUrl; + _applyNowPlayingMediaInfo(); + }).catchError((e) { + debugPrint('NativeVideoStore: profile image fetch failed: $e'); + }); + } + + await _controller!.initialize(); + if (_disposed || generation != _initGeneration) { + // Don't reset _initInFlight here — a newer generation's _initPlayer + // may already own the flag. Only the current generation should touch it. + return; + } + + _adBreakSub = _controller!.adBreakStream.listen((isActive) { + _isAdBreakActive = isActive; + debugPrint( + 'NativeVideoStore: ad break ${isActive ? 'started' : 'ended'}', + ); + if (!settingsStore.blockTwitchAds) { + runInAction(() => _blockingAds = false); + return; + } + // Fire-and-forget: swap stream URL per TwitchAdSolutions backup path. + unawaited(_onAdBreakChanged(isActive)); + }); + + _pipSub = _controller!.isPipEnabledStream.listen((isPip) { + runInAction(() { + // iOS-only bookkeeping: track whether PiP was triggered by gesture + // (auto) or button (manual). Must run before _setPipActive mutates + // _isInPipMode. + if (isPip && !_isInPipMode) { + _lastPipWasAutomatic = !_manualPipRequested; + _manualPipRequested = false; + } else if (!isPip && _isInPipMode) { + _lastPipWasAutomatic = false; + } + _setPipActive(isPip); + }); + }); + + _qualitiesSub = _controller!.qualitiesStream.listen((qualities) { + runInAction(() { + // Filter out the plugin's auto entry (we add our own) and deduplicate. + final seen = {}; + final filtered = qualities + .where((q) => !q.isAuto && seen.add(q.label)) + .toList(); + + // Remove bare "{height}p" entries when a fps-suffixed version + // (e.g. "160p30") exists at the same height. The bare entry is + // typically a duplicate HLS variant with missing FRAME-RATE that + // causes frozen video when selected. + final labels = filtered.map((q) => q.label).toSet(); + filtered.removeWhere((q) { + final bare = _bareResolutionRe.firstMatch(q.label); + if (bare == null) return false; + final height = bare.group(1)!; + return labels.any( + (l) => l != q.label && l.startsWith('${height}p'), + ); + }); + + // Reverse so highest quality comes first. + _qualityObjects = filtered.reversed.toList(); + _availableStreamQualities = [ + 'Auto', + ..._qualityObjects.map((q) => q.label), + ]; + + if (_firstTimeSettingQuality && qualities.isNotEmpty) { + _firstTimeSettingQuality = false; + if (settingsStore.defaultToHighestQuality) { + _queueOrApplyInitialQualityIndex(1); + } else { + SharedPreferences.getInstance().then((prefs) { + if (_disposed || _pendingQualityIndex != null) return; + final lastQuality = + prefs.getString(lastStreamQualityKey(userLogin)); + if (lastQuality == null) return; + final index = _availableStreamQualities.indexOf(lastQuality); + if (index != -1) { + runInAction(() => _queueOrApplyInitialQualityIndex(index)); + } + }).catchError((e) { + debugPrint('NativeVideoStore: failed to read last quality: $e'); + }); + } + } + }); + }); + + _controllerInitialized = true; + } catch (e, stackTrace) { + if (_disposed || generation != _initGeneration) return; + _initInFlight = false; + await _handleLoadError(e, stackTrace, generation); + return; + } + _initInFlight = false; + await _runLoadStream(generation); + } + + /// Wraps `_loadStream` with shared success/error handling. On success, + /// clears `_error`; on failure, delegates to `_handleLoadError` for retry + /// scheduling or error display. Used by both first-time init and refresh. + Future _runLoadStream(int generation) async { + try { + await _loadStream(generation); + if (_disposed || generation != _initGeneration) return; + runInAction(() { + _error = null; + }); + } catch (e, stackTrace) { + if (_disposed || generation != _initGeneration) return; + await _handleLoadError(e, stackTrace, generation); + } + } + + /// Fetches a fresh playback token, builds the HLS URL, and loads it into + /// the (already-initialized) controller. Used by both first-time init and + /// user-initiated refresh (light refresh reuses the same AVPlayer via + /// replaceCurrentItem internally, per Apple's HLS recovery guidance). + Future _loadStream(int generation) async { + // Use web cookie token (works with web Client-ID) for ad-free playback. + final authToken = authStore.gqlToken; + late final String hlsUrl; + late final PlaybackAccessToken token; + try { + if (settingsStore.blockTwitchAds) { + final resolved = await _adFreePlayback.resolvePrimary( + login: userLogin, + authToken: authToken, + ); + hlsUrl = resolved.url; + token = resolved.token; + debugPrint( + 'NativeVideoStore: adblock primary ' + 'playerType=${resolved.playerType}', + ); + } else { + token = await twitchGqlApi.getPlaybackAccessToken( + login: userLogin, + authToken: authToken, + ); + hlsUrl = twitchGqlApi.buildHlsUrl(login: userLogin, token: token); + } + } catch (e) { + if (authToken != null) { + debugPrint('NativeVideoStore: auth token failed, retrying without: $e'); + if (e is UnauthorizedException) { + // Twitch rejected the web session token — unlink it so the UI + // reflects the true state and future loads skip this request. + authStore.invalidateGqlToken(); + } + if (settingsStore.blockTwitchAds) { + final resolved = await _adFreePlayback.resolvePrimary( + login: userLogin, + ); + hlsUrl = resolved.url; + token = resolved.token; + } else { + token = await twitchGqlApi.getPlaybackAccessToken(login: userLogin); + hlsUrl = twitchGqlApi.buildHlsUrl(login: userLogin, token: token); + } + } else { + rethrow; + } + } + + if (_disposed || generation != _initGeneration || !settingsStore.showVideo) { + return; + } + runInAction(() { + _restrictedStreamQualities = token.restrictedQualities; + _usingAdBackup = false; + _blockingAds = false; + }); + + await _controller!.loadUrl( + url: hlsUrl, + headers: TwitchGqlApi.playbackHttpHeaders, + ); + if (_disposed || generation != _initGeneration || !settingsStore.showVideo) { + return; + } + await _controller!.configureForLivePlayback(); + _startLatencyPolling(); + } + + /// TwitchAdSolutions backup swap when the native player reports a stitched + /// ad break; restores the primary (popout) URL when the break ends. + Future _onAdBreakChanged(bool isActive) async { + if (_disposed || !settingsStore.blockTwitchAds || _controller == null) { + return; + } + if (_adSwapInFlight) return; + _adSwapInFlight = true; + final generation = _initGeneration; + try { + if (isActive) { + runInAction(() => _blockingAds = true); + final resolved = await _adFreePlayback.resolveBackup( + login: userLogin, + authToken: authStore.gqlToken, + ); + if (_disposed || generation != _initGeneration) return; + debugPrint( + 'NativeVideoStore: blocking ads via ' + 'playerType=${resolved.playerType}', + ); + await _controller!.loadUrl( + url: resolved.url, + headers: TwitchGqlApi.playbackHttpHeaders, + ); + if (_disposed || generation != _initGeneration) return; + await _controller!.configureForLivePlayback(); + runInAction(() { + _usingAdBackup = true; + _restrictedStreamQualities = resolved.token.restrictedQualities; + }); + } else if (_usingAdBackup) { + debugPrint('NativeVideoStore: ad break ended, restoring primary'); + final resolved = await _adFreePlayback.resolvePrimary( + login: userLogin, + authToken: authStore.gqlToken, + ); + if (_disposed || generation != _initGeneration) return; + await _controller!.loadUrl( + url: resolved.url, + headers: TwitchGqlApi.playbackHttpHeaders, + ); + if (_disposed || generation != _initGeneration) return; + await _controller!.configureForLivePlayback(); + runInAction(() { + _usingAdBackup = false; + _blockingAds = false; + _restrictedStreamQualities = resolved.token.restrictedQualities; + }); + } else { + runInAction(() => _blockingAds = false); + } + } catch (e) { + debugPrint('NativeVideoStore: adblock swap failed: $e'); + runInAction(() => _blockingAds = _isAdBreakActive || _usingAdBackup); + } finally { + _adSwapInFlight = false; + } + } + + /// Shared error handler for both `_initPlayer` and `_reloadCurrentController`. + /// Checks stream liveness, auto-retries if CDN is still serving stale + /// manifests, or surfaces the error with the overlay visible. + Future _handleLoadError( + Object e, + StackTrace stackTrace, + int generation, + ) async { + _initializing = false; + // Wait for stream info so we can distinguish "offline" from "broken". + await updateStreamInfo(forceUpdate: true); + if (_disposed || generation != _initGeneration) return; + debugPrint('NativeVideoStore load error: $e'); + if (_streamInfo != null) { + _recordNativePlaybackError( + e, + stackTrace, + 'Native video load failed', + information: [ + 'channel=$userLogin', + 'attempt=$_totalRefreshAttempts', + ], + ); + } + + // Auto-retry if the stream is live — the CDN may still be serving + // stale manifests right after a stream crash. + if (_streamInfo != null && + _totalRefreshAttempts < VideoTimingConstants.maxRefreshAttempts) { + _totalRefreshAttempts++; + debugPrint( + 'NativeVideoStore: auto-retrying load ' + '(attempt $_totalRefreshAttempts/${VideoTimingConstants.maxRefreshAttempts})', + ); + _initRetryTimer?.cancel(); + _initRetryTimer = Timer(VideoTimingConstants.initRetryDelay, () { + if (!_disposed && generation == _initGeneration) { + _handleRefresh(userInitiated: false); + } + }); + return; + } + + runInAction(() { + _loading = false; + _paused = true; + // Only show the error if the stream is actually live — an offline + // channel is expected to fail here and the overlay handles it. + if (_streamInfo != null) { + _showErrorOverlay( + 'Native player failed to load. Try $_legacyWebViewInSettings.', + ); + } + }); + } + + /// Light refresh — reloads the stream URL on the existing controller. + /// Skips platform view recreation, audio session churn, and subscription + /// teardown. Per Apple's WWDC 2017 guidance, this is the preferred + /// pattern: new AVPlayerItem via `replaceCurrentItem` on existing AVPlayer. + @action + Future _reloadCurrentController() => _runLoadStream(_initGeneration); + + void _onPlayerActivity(PlayerActivityEvent event) { + runInAction(() { + switch (event.state) { + case PlayerActivityState.playing: + _onPlaying(); + case PlayerActivityState.buffering: + case PlayerActivityState.loading: + _onBufferingOrLoading(event.state); + case PlayerActivityState.error: + _onPlayerError(event.data); + case PlayerActivityState.paused: + case PlayerActivityState.stopped: + case PlayerActivityState.completed: + case PlayerActivityState.idle: + _onPaused(); + default: + break; + } + }); + } + + /// Keeps the Android PiP window's play/pause remote action in sync with + /// actual playback. Without this the button is stuck on whatever glyph the + /// `mediaOnlyPause` layout started with, and `onPipAction` — which toggles + /// blind — then does the opposite of what the button shows. + void _syncPipPlaybackState({required bool isPlaying}) { + if (!Platform.isAndroid) return; + _pip.setIsPlaying(isPlaying); + } + + void _onPlaying() { + _loading = false; + _paused = false; + _syncPipPlaybackState(isPlaying: true); + _isStalled = false; + _hasPlayedOnce = true; + _initializing = false; + _isQualitySwitching = false; + _stallRecoveryTimer?.cancel(); + _stallRecoveryAttempt = 0; + _lastPlayingStart = DateTime.now(); + _scheduleHealthyPlaybackRecoveryReset(_lastPlayingStart!); + if (_pendingQualityIndex != null) { + final index = _pendingQualityIndex!; + _pendingQualityIndex = null; + _setStreamQualityIndex(index); + } + _updateLatency(); + } + + void _onBufferingOrLoading(PlayerActivityState state) { + if (!_isQualitySwitching && !_hasPlayedOnce) { + _loading = true; + } + // Track mid-stream stalls separately from initial loading. With + // automaticallyWaitsToMinimizeStalling = false, AVPlayer silently drops + // rate to 0 when the buffer empties. The stall timer needs this flag + // to engage recovery on those stalls. + if (_hasPlayedOnce && !_isQualitySwitching) { + _isStalled = true; + // Ad-pod entry/exit produces short play/buffer bounces that would + // otherwise count toward the CDN-trouble error heuristic. + if (!_isAdBreakActive) _recordShortPlayBounceIfNeeded(); + } + // Clear _initializing on the first buffering event so the stall + // recovery timer isn't permanently blocked during init stalls. + if (_initializing && state == PlayerActivityState.buffering) { + _initializing = false; + } + if (!_userPaused && !_initializing) _startStallRecoveryTimer(); + } + + void _onPlayerError(Map? eventData) { + _loading = false; + _paused = true; + _syncPipPlaybackState(isPlaying: false); + _isStalled = false; + _initializing = false; + _isQualitySwitching = false; + _stallRecoveryTimer?.cancel(); + final errorMessage = + eventData?['message'] as String? ?? + 'Playback error. Try refreshing or switch to $_legacyWebViewInSettings.'; + final errorCode = eventData?['code'] as String?; + final statusCode = eventData?['statusCode'] as int?; + + if (errorCode == 'mediaServicesWereReset') { + _handleMediaServicesReset(errorMessage); + return; + } + + // Mid-stream failures (expired HLS session, ExoPlayer "Source error" + // after screen-off / network switch, CDN blips). A fresh token + reload + // usually fixes these — only pin the Reload overlay once retries are + // spent. 4xx refreshes immediately; network/source waits briefly so the + // interface can come back. + if (shouldAutoRecoverFromPlayerError( + refreshCapReached: _refreshCapReached(), + errorCode: errorCode, + )) { + if (_userPaused) { + // Don't yank a user-paused stream back to life — the refresh would + // reload with autoplay. Defer until they resume. + _pendingRefreshOnResume = true; + return; + } + final attempt = _totalRefreshAttempts + 1; + final delay = playerErrorRetryDelay(statusCode: statusCode); + debugPrint( + 'NativeVideoStore: player error' + '${statusCode != null ? ' (HTTP $statusCode)' : ''}' + '${errorMessage.isNotEmpty ? ' — $errorMessage' : ''}, ' + 'auto-refresh in ${delay.inMilliseconds}ms ' + '(attempt $attempt/${VideoTimingConstants.maxRefreshAttempts})', + ); + _totalRefreshAttempts = attempt; + _loading = true; + _error = null; + _initRetryTimer?.cancel(); + final generation = _initGeneration; + if (delay == Duration.zero) { + _handleRefresh(userInitiated: false); + } else { + _initRetryTimer = Timer(delay, () { + if (_disposed || generation != _initGeneration) return; + // _handleRefresh parks behind _pendingRefreshOnResume when paused. + _handleRefresh(userInitiated: false); + }); + } + return; + } + + // Check stream liveness before showing the error — an offline channel + // is expected to 404 and the overlay handles it. + final generation = _initGeneration; + updateStreamInfo(forceUpdate: true).then((_) { + if (_disposed || generation != _initGeneration) return; + runInAction(() { + if (_streamInfo != null) { + _recordNativePlaybackError( + StateError(errorMessage), + StackTrace.current, + 'Native video player error', + information: [ + 'channel=$userLogin', + if (errorCode != null) 'code=$errorCode', + if (statusCode != null) 'statusCode=$statusCode', + ], + ); + _showErrorOverlay(errorMessage); + } + }); + }); + } + + /// Surfaces [message] on the video overlay and pins it (cancels auto-hide). + /// Must be called inside an action context. + void _showErrorOverlay(String message) { + _error = message; + _overlayVisible = true; + _overlayTimer?.cancel(); + } + + void _recordNativePlaybackError( + Object error, + StackTrace stackTrace, + String reason, { + List information = const [], + }) { + try { + FirebaseCrashlytics.instance.recordError( + error, + stackTrace, + reason: reason, + information: information, + ); + } catch (_) { + // Firebase may not be initialized (e.g., in tests). + } + } + + void _handleMediaServicesReset(String errorMessage) { + if (_refreshCapReached()) { + runInAction(() { + _disposeController(); + _loading = false; + _paused = true; + _isStalled = false; + _initializing = false; + _isQualitySwitching = false; + _showErrorOverlay(errorMessage); + }); + return; + } + + if (_userPaused) { + // The player is orphaned either way, but don't auto-resume a stream + // the user paused — rebuild when they tap play. + _disposeController(); + _pendingRefreshOnResume = true; + return; + } + + _totalRefreshAttempts++; + debugPrint( + 'NativeVideoStore: media services reset, rebuilding player ' + '(attempt $_totalRefreshAttempts/${VideoTimingConstants.maxRefreshAttempts})', + ); + _disposeController(); + unawaited(_handleRefresh(userInitiated: false)); + } + + bool _refreshCapReached() => + _totalRefreshAttempts >= VideoTimingConstants.maxRefreshAttempts; + + void _onPaused() { + _healthyPlaybackTimer?.cancel(); + _paused = true; + _syncPipPlaybackState(isPlaying: false); + _isStalled = false; + _isQualitySwitching = false; + _stallRecoveryTimer?.cancel(); + + // If the player auto-paused during startup/buffering before playing once, + // and the user didn't explicitly pause it, force resume and trigger recovery. + if (!_userPaused && !_hasPlayedOnce && !_initializing && _error == null) { + debugPrint('NativeVideoStore: auto-paused before first play, triggering play'); + _controller?.play(); + _startStallRecoveryTimer(); + } + } + + void _startStallRecoveryTimer() { + _stallRecoveryTimer?.cancel(); + _stallRecoveryTimer = Timer( + VideoTimingConstants.stallDetectionDelay, + _runStallRecovery, + ); + } + + Future _runStallRecovery() async { + if (_disposed || + (!_loading && !_isStalled) || + _userPaused || + _initializing) { + return; + } + + // Note: recovery deliberately does NOT defer during ad breaks. Ad-pod + // transition glitches resolve well inside the 8s stall window, attempt-1 + // recovery (seek to edge + play) is harmless mid-pod, and the native + // ad-break flag can freeze at true when paused (the time observer that + // re-evaluates it stops firing) — gating recovery on it deadlocks the + // player into a permanent pause. + + // When a stream ends the HLS server stops serving segments, causing + // the player to stall indefinitely. Skip recovery if the stream is + // offline — the overlay handles that state. + await updateStreamInfo(forceUpdate: true); + if (_disposed || _streamInfo == null) return; + + _stallRecoveryAttempt++; + + if (_stallRecoveryAttempt <= 1) { + _recoverBySeekingToLiveEdge(); + } else if (_refreshCapReached()) { + _showStallError(); + } else if (_backoffDelayRemaining() > Duration.zero) { + // Still within the exponential backoff window — re-arm and try again. + debugPrint('NativeVideoStore: refresh backoff pending, re-arming timer'); + _startStallRecoveryTimer(); + } else { + _recoverByFullRefresh(); + } + } + + void _recoverBySeekingToLiveEdge() { + debugPrint('NativeVideoStore: stall detected, seeking to live edge'); + _controller?.seekToLiveEdge(); + _controller?.play(); + _startStallRecoveryTimer(); + } + + void _showStallError() { + debugPrint('NativeVideoStore: max recovery attempts reached'); + final bouncing = _shortPlayBounceCount >= + VideoTimingConstants.shortPlayLoopThreshold; + runInAction(() { + _loading = false; + _showErrorOverlay( + bouncing + ? 'Twitch is having trouble with this channel right now. Try again in a moment, or switch to $_legacyWebViewInSettings.' + : 'Stream stalled. Try refreshing or switch to $_legacyWebViewInSettings.', + ); + }); + } + + /// If a stall arrives within [shortPlayWindow] of the last `_onPlaying`, + /// count it as a bounce. Three bounces in a row flips the stall-error + /// message to the CDN-trouble variant. + void _recordShortPlayBounceIfNeeded() { + _healthyPlaybackTimer?.cancel(); + final start = _lastPlayingStart; + if (start == null) return; + final elapsed = DateTime.now().difference(start); + if (elapsed <= VideoTimingConstants.shortPlayWindow) { + _shortPlayBounceCount++; + } else { + _shortPlayBounceCount = 0; + } + _lastPlayingStart = null; + } + + void _scheduleHealthyPlaybackRecoveryReset(DateTime playingStartedAt) { + _healthyPlaybackTimer?.cancel(); + _healthyPlaybackTimer = Timer(VideoTimingConstants.shortPlayWindow, () { + if (_disposed || + _isStalled || + _lastPlayingStart != playingStartedAt) { + return; + } + runInAction(() { + _totalRefreshAttempts = 0; + _shortPlayBounceCount = 0; + _lastPlayingStart = null; + }); + }); + } + + /// How long to wait before the next stall-recovery full refresh, based on + /// how many have already fired. Returns zero once enough time has elapsed + /// since the last refresh, or when no refresh has happened yet. + Duration _backoffDelayRemaining() { + if (_lastRefreshTime == null) return Duration.zero; + final idx = _totalRefreshAttempts + .clamp(0, VideoTimingConstants.refreshBackoff.length - 1); + final required = VideoTimingConstants.refreshBackoff[idx]; + final elapsed = DateTime.now().difference(_lastRefreshTime!); + final remaining = required - elapsed; + return remaining > Duration.zero ? remaining : Duration.zero; + } + + void _recoverByFullRefresh() { + _totalRefreshAttempts++; + debugPrint( + 'NativeVideoStore: stall persists, refreshing player ' + '(attempt $_totalRefreshAttempts/${VideoTimingConstants.maxRefreshAttempts})', + ); + _handleRefresh(userInitiated: false); + } + + void _startStreamInfoTimer() { + _streamInfoTimer?.cancel(); + _streamInfoTimer = Timer.periodic( + VideoTimingConstants.nativeStreamInfoInterval, + (_) => _pollStreamInfoAndRecoverIfNeeded(), + ); + } + + void _stopStreamInfoTimer() { + _streamInfoTimer?.cancel(); + _streamInfoTimer = null; + } + + Future _pollStreamInfoAndRecoverIfNeeded() async { + if (_disposed) return; + final wasOffline = _streamInfo == null; + await updateStreamInfo(forceUpdate: true); + if (_disposed) return; + + if (wasOffline && _streamInfo != null && settingsStore.showVideo && !_userPaused) { + debugPrint( + 'NativeVideoStore: stream detected back online, auto-refreshing', + ); + _handleRefresh(userInitiated: false); + } + } + + void _startLatencyPolling() { + _latencyTimer?.cancel(); + _updateLatency(); + _latencyTimer = Timer.periodic( + VideoTimingConstants.latencyPollingInterval, + (_) => _updateLatency(), + ); + } + + Future _updateLatency() async { + // Program-date-time readings jump around during stitched ad breaks — + // skip the poll entirely (display, recovery, and chat sync all resume + // on the next tick after the break ends). + if (_isAdBreakActive) return; + + final seconds = await _controller?.getLatencyToLive(); + if (_disposed || seconds == null) return; + final rounded = seconds.round(); + + // Auto-recover when latency climbs too high (e.g. stream glitch, + // temporary network issue). Normal live latency is under ~15s; + // 30s+ means the player fell behind and needs intervention. + // Intentionally skips the latency display update and chat delay + // sync — both are stale at this point and will refresh after recovery. + if (rounded >= VideoTimingConstants.highLatencyThresholdSeconds && + !_paused && + !_loading && + !_userPaused) { + _highLatencyCount++; + // Still high after several interventions — the reading can't be + // trusted (device clock skew) or the device genuinely can't keep up. + // Either way, looping seeks/refreshes forever makes playback worse. + // The counter survives refreshes and only resets on a healthy + // reading, so a persistent condition stops recovery permanently. + if (_highLatencyCount > + VideoTimingConstants.maxHighLatencyRecoveries) { + return; + } + if (_isInPipMode || _highLatencyCount == 1) { + // During PiP, only seek to live edge — handleRefresh() disposes + // the controller which kills PiP. + debugPrint( + 'NativeVideoStore: high latency (${rounded}s), seeking to live edge', + ); + _controller?.seekToLiveEdge(); + _controller?.play(); + } else if (!_refreshCapReached() && + _backoffDelayRemaining() == Duration.zero) { + debugPrint( + 'NativeVideoStore: latency still high (${rounded}s), refreshing ' + '(attempt ${_totalRefreshAttempts + 1}/${VideoTimingConstants.maxRefreshAttempts})', + ); + _totalRefreshAttempts++; + _handleRefresh(userInitiated: false); + } + // Within the refresh cap/backoff window — wait for the next poll. + return; + } + _highLatencyCount = 0; + + final newLatency = '${rounded}s'; + if (newLatency != _latency) { + runInAction(() { + _latency = newLatency; + }); + } + + // Skip chat sync when the player isn't actively progressing against + // the live edge — a stall/pause leaves the live edge racing ahead and + // would inflate syncedChatDelay unbounded. + if (_paused || _loading || _userPaused || _isStalled) return; + _chatLatencySync.report(seconds); + } + + void _scheduleOverlayHide([ + Duration delay = VideoTimingConstants.overlayAutoHide, + ]) { + _overlayTimer?.cancel(); + + if (_isInPipMode) { + runInAction(() => _overlayVisible = true); + return; + } + + _overlayTimer = Timer(delay, () { + if (_isInPipMode) return; + runInAction(() { + _overlayVisible = false; + }); + }); + } + + @override + @action + void handleVideoTap() { + if (_isInPipMode) { + _overlayVisible = true; + return; + } + + _overlayTimer?.cancel(); + + if (_overlayVisible) { + _overlayVisible = false; + } else { + updateStreamInfo(forceUpdate: true); + _overlayVisible = true; + _scheduleOverlayHide(); + } + } + + @override + void handlePausePlay() { + if (_pendingRefreshOnResume) { + // The playback session died while user-paused (token expiry, media + // services reset) — playing the dead session would no-op or error. + _pendingRefreshOnResume = false; + _handleRefresh(userInitiated: false); + return; + } + if (_controller == null) return; + if (_paused) { + _userPaused = false; + _controller!.play(); + } else { + _userPaused = true; + _controller!.pause(); + } + } + + @override + @action + void handleToggleOverlay() { + if (settingsStore.toggleableOverlay) { + HapticFeedback.mediumImpact(); + settingsStore.showOverlay = !settingsStore.showOverlay; + + if (settingsStore.showOverlay) { + _overlayVisible = true; + _scheduleOverlayHide(VideoTimingConstants.overlayQuickHide); + } + } + } + + @override + @action + Future handleRefresh() => _handleRefresh(userInitiated: true); + + Future _handleRefresh({required bool userInitiated}) { + // Auto-recovery must never resume a stream the user paused — park the + // refresh until they tap play (handlePausePlay consumes the flag). The + // error paths set the flag themselves to preserve attempt counters; + // this guard covers every other producer (e.g. the init-retry timer). + if (!userInitiated && _userPaused) { + _pendingRefreshOnResume = true; + return Future.value(); + } + + if (userInitiated) HapticFeedback.lightImpact(); + + final wasControllerInitialized = _controllerInitialized; + runInAction(() { + // Reset recovery cap, cooldown, and bounce counter on user-initiated + // refresh (error was shown). Stall recovery calls this with + // _error == null, preserving the cap. + if (userInitiated && _error != null) { + _totalRefreshAttempts = 0; + _shortPlayBounceCount = 0; + } + _lastRefreshTime = DateTime.now(); + // PiP state is preserved here: the light path reuses the controller + // (flags must stay in sync with the native player) and the hard path + // resets it via _disposeController below. _highLatencyCount survives + // so the latency-recovery cap can stop a persistent loop; it resets + // on the next healthy reading. + _resetPlayerStateCommon( + preservePipState: true, + preserveHighLatencyCount: true, + ); + _loading = true; + _initializing = true; + _initGeneration++; + + _overlayTimer?.cancel(); + _scheduleOverlayHide(); + _latencyTimer?.cancel(); + _stallRecoveryTimer?.cancel(); + _initRetryTimer?.cancel(); + _healthyPlaybackTimer?.cancel(); + + if (wasControllerInitialized) return; + + // Hard refresh: controller isn't ready (init in progress or failed). + // Tear down and rebuild from scratch. + _isInPipMode = false; + _lastPipWasAutomatic = false; + _manualPipRequested = false; + _overlayWasVisibleBeforePip = true; + _disposeController(); + _controller = _createController(); + }); + + if (wasControllerInitialized) { + // Light refresh: reload stream on existing controller. PiP state is + // intentionally preserved — the controller is reused, so its observable + // PiP flags must stay in sync with the native player. + unawaited(_reloadCurrentController()); + } else { + unawaited(_initPlayer()); + } + unawaited(updateStreamInfo()); + return Future.value(); + } + + @override + void requestPictureInPicture() { + if (Platform.isAndroid) { + // autoEnter for future background transitions is managed by the showVideo + // reaction (setAutoPipMode) — don't also pass it here. + _pip.enterPipMode(); + } else { + // Skip manual PiP if the last session was auto-triggered (swipe gesture). + // Entering manual PiP right after auto-PiP causes gray window artifacts + // because the two PiP systems (AVPlayerViewController vs custom controller) + // conflict on the same player. + if (_lastPipWasAutomatic) return; + _manualPipRequested = true; + _controller?.enterPictureInPicture(); + } + } + + @override + @action + void togglePictureInPicture() { + if (_isInPipMode) { + // iOS has a programmatic exit; Android does not — the user must close + // the PiP window themselves, so the toggle is a no-op there. + if (Platform.isIOS) _controller?.exitPictureInPicture(); + return; + } + requestPictureInPicture(); + } + + @override + @action + Future updateStreamQualities() async { + // Qualities are populated automatically via qualitiesStream. + } + + @override + @action + Future setStreamQuality(String quality) async { + final index = _availableStreamQualities.indexOf(quality); + if (index == -1) return; + await _setStreamQualityIndex(index); + } + + @action + Future _setStreamQualityIndex(int index) async { + _streamQualityIndex = index; + final controller = _controller; + if (controller == null) return; + + _isQualitySwitching = true; + // A resolution-only switch never interrupts playback, so no play/pause + // event arrives to clear the flag — expire it after a grace window or + // it sticks for the session and disables stall classification. + _qualitySwitchGraceTimer?.cancel(); + _qualitySwitchGraceTimer = Timer( + VideoTimingConstants.qualitySwitchGrace, + () { + if (_disposed) return; + runInAction(() => _isQualitySwitching = false); + }, + ); + + if (index == 0) { + // 'Auto' — reset to adaptive quality + await controller.setQuality(NativeVideoPlayerQuality.auto()); + } else { + final qualityIndex = index - 1; + if (qualityIndex >= 0 && qualityIndex < _qualityObjects.length) { + await controller.setQuality(_qualityObjects[qualityIndex]); + } + } + + // The controller can be disposed or replaced during the platform call + // (user backs out, chat-only toggle, media services reset). + if (_disposed || !identical(controller, _controller)) return; + + // Toggle background PiP based on audio-only mode. + // Only call when the mode actually changes to avoid unnecessary + // method channel round-trips on every quality switch. + final quality = index == 0 + ? null + : _qualityObjects.elementAtOrNull(index - 1); + final isAudioOnly = + quality != null && quality.width == 0 && quality.height == 0; + if (isAudioOnly != _isAudioOnlyMode) { + _isAudioOnlyMode = isAudioOnly; + if (Platform.isIOS) { + if (isAudioOnly) { + controller.disableAutomaticInlinePip(); + } else { + controller.enableAutomaticInlinePip(); + } + } else if (Platform.isAndroid && _autoPipAvailable == true) { + if (isAudioOnly) { + _pip.setAutoPipMode(autoEnter: false); + } else if (settingsStore.showVideo) { + _pip.setAutoPipMode(); + } + } + } + } + + void _queueOrApplyInitialQualityIndex(int index) { + if (_hasPlayedOnce) { + unawaited(_setStreamQualityIndex(index)); + } else { + _pendingQualityIndex = index; + } + } + + @override + @action + Future updateStreamInfo({bool forceUpdate = false}) async { + final result = await _streamInfoPoller.fetch(forceUpdate: forceUpdate); + if (_disposed || result == null) return; + runInAction(() { + if (result.stream != null) { + _streamInfo = result.stream; + _offlineChannelInfo = null; + return; + } + if (result.offlineChannel == null) { + return; + } + _overlayTimer?.cancel(); + _latencyTimer?.cancel(); + _stallRecoveryTimer?.cancel(); + _healthyPlaybackTimer?.cancel(); + _loading = false; + _latency = null; + _streamInfo = null; + _offlineChannelInfo = result.offlineChannel; + // Confirmed offline (transient errors returned above) — flush the + // chat delay since there's no video to sync against. + _chatLatencySync.reset(); + }); + } + + @override + @action + void handleAppResume() { + if (_isInPipMode) return; + _highLatencyCount = 0; + updateStreamInfo(forceUpdate: true); + + if (_userPaused || _initializing) return; + + // After screen-off / a network drop the native player is often in an + // error state (ExoPlayer "Source error"). play() alone cannot revive a + // dead session — refresh like the Reload button would. + if (_error != null || + _pendingRefreshOnResume || + _controller == null || + !_controllerInitialized) { + _pendingRefreshOnResume = false; + unawaited(_handleRefresh(userInitiated: false)); + return; + } + + _controller!.play(); + } + + @override + @action + void handleAndroidPipChanged(bool isInPip) => _setPipActive(isInPip); + + /// Shared PiP enter/exit overlay state machine, driven by the Android + /// activity callback and the iOS controller's PiP stream. + void _setPipActive(bool isInPip) { + if (isInPip && !_isInPipMode) { + _overlayWasVisibleBeforePip = _overlayVisible; + _isInPipMode = true; + _overlayTimer?.cancel(); + _overlayVisible = true; + } else if (!isInPip && _isInPipMode) { + _isInPipMode = false; + if (_overlayWasVisibleBeforePip) { + _scheduleOverlayHide(); + } else { + _overlayVisible = false; + } + } + } + + @override + @action + void dispose() { + _disposed = true; + + if (Platform.isAndroid && _autoPipAvailable == true) { + _pip.setAutoPipMode(autoEnter: false); + } + + _overlayTimer?.cancel(); + _stopStreamInfoTimer(); + _disposeAndroidAutoPipReaction?.call(); + _disposeVideoModeReaction?.call(); + _disposeController(); + } + + /// Tears down the native player and all of its observers. Releases the + /// `AVPlayer`, deactivates the audio session (when this is the last player + /// alive), and stops the PiP controller. Safe to call when no controller + /// has been created. + void _disposeController() { + _latencyTimer?.cancel(); + _stallRecoveryTimer?.cancel(); + _initRetryTimer?.cancel(); + _healthyPlaybackTimer?.cancel(); + _qualitySwitchGraceTimer?.cancel(); + _pipSub?.cancel(); + _pipSub = null; + _qualitiesSub?.cancel(); + _qualitiesSub = null; + _adBreakSub?.cancel(); + _adBreakSub = null; + _controller?.removeActivityListener(_onPlayerActivity); + _controller?.dispose(); + _controller = null; + _controllerInitialized = false; + _initInFlight = false; + _initGeneration++; + } + + /// Resets the observable fields that have the same target value across + /// "torn down for chat-only" and "fresh controller about to load". Caller + /// sets the few fields that differ (`_loading`, `_initializing`). + void _resetPlayerStateCommon({ + bool preservePipState = false, + bool preserveHighLatencyCount = false, + }) { + _hasPlayedOnce = false; + _userPaused = false; + _pendingRefreshOnResume = false; + _paused = true; + _isStalled = false; + _isAdBreakActive = false; + _usingAdBackup = false; + _blockingAds = false; + _adSwapInFlight = false; + _restrictedStreamQualities = []; + _isQualitySwitching = false; + _isAudioOnlyMode = false; + _stallRecoveryAttempt = 0; + if (!preserveHighLatencyCount) _highLatencyCount = 0; + _firstTimeSettingQuality = true; + _pendingQualityIndex = null; + _streamQualityIndex = 0; + _availableStreamQualities = []; + _qualityObjects = []; + _latency = null; + _error = null; + if (!preservePipState) { + _isInPipMode = false; + _lastPipWasAutomatic = false; + _manualPipRequested = false; + _overlayWasVisibleBeforePip = true; + } + } + + /// Resets observable display state after [_disposeController] so the UI + /// reflects "no active player". Called only when entering chat-only mode; + /// final disposal skips this since the store is being torn down. + @action + void _resetPlayerStateForChatOnly() { + _resetPlayerStateCommon(); + _loading = false; + _initializing = false; + _chatLatencySync.reset(); + } + + /// Builds a brand new controller and kicks off `_initPlayer`. Used when + /// the user toggles back to video mode after a chat-only stretch. + @action + void _initFreshController() { + _resetPlayerStateCommon(); + _loading = true; + _initializing = true; + _controller = _createController(); + _initPlayer(); + } +} diff --git a/lib/screens/channel/video/native_video_store.g.dart b/lib/screens/channel/video/native_video_store.g.dart new file mode 100644 index 00000000..afce5344 --- /dev/null +++ b/lib/screens/channel/video/native_video_store.g.dart @@ -0,0 +1,517 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'native_video_store.dart'; + +// ************************************************************************** +// StoreGenerator +// ************************************************************************** + +// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers + +mixin _$NativeVideoStore on NativeVideoStoreBase, Store { + Computed? _$streamQualityComputed; + + @override + String get streamQuality => (_$streamQualityComputed ??= Computed( + () => super.streamQuality, + name: 'NativeVideoStoreBase.streamQuality', + )).value; + + late final _$_controllerAtom = Atom( + name: 'NativeVideoStoreBase._controller', + context: context, + ); + + NativeVideoPlayerController? get controller { + _$_controllerAtom.reportRead(); + return super._controller; + } + + @override + NativeVideoPlayerController? get _controller => controller; + + @override + set _controller(NativeVideoPlayerController? value) { + _$_controllerAtom.reportWrite(value, super._controller, () { + super._controller = value; + }); + } + + late final _$_loadingAtom = Atom( + name: 'NativeVideoStoreBase._loading', + context: context, + ); + + bool get loading { + _$_loadingAtom.reportRead(); + return super._loading; + } + + @override + bool get _loading => loading; + + @override + set _loading(bool value) { + _$_loadingAtom.reportWrite(value, super._loading, () { + super._loading = value; + }); + } + + late final _$_blockingAdsAtom = Atom( + name: 'NativeVideoStoreBase._blockingAds', + context: context, + ); + + bool get blockingAds { + _$_blockingAdsAtom.reportRead(); + return super._blockingAds; + } + + @override + bool get _blockingAds => blockingAds; + + @override + set _blockingAds(bool value) { + _$_blockingAdsAtom.reportWrite(value, super._blockingAds, () { + super._blockingAds = value; + }); + } + + late final _$_pausedAtom = Atom( + name: 'NativeVideoStoreBase._paused', + context: context, + ); + + bool get paused { + _$_pausedAtom.reportRead(); + return super._paused; + } + + @override + bool get _paused => paused; + + @override + set _paused(bool value) { + _$_pausedAtom.reportWrite(value, super._paused, () { + super._paused = value; + }); + } + + late final _$_hasPlayedOnceAtom = Atom( + name: 'NativeVideoStoreBase._hasPlayedOnce', + context: context, + ); + + bool get hasPlayedOnce { + _$_hasPlayedOnceAtom.reportRead(); + return super._hasPlayedOnce; + } + + @override + bool get _hasPlayedOnce => hasPlayedOnce; + + @override + set _hasPlayedOnce(bool value) { + _$_hasPlayedOnceAtom.reportWrite(value, super._hasPlayedOnce, () { + super._hasPlayedOnce = value; + }); + } + + late final _$_overlayVisibleAtom = Atom( + name: 'NativeVideoStoreBase._overlayVisible', + context: context, + ); + + bool get overlayVisible { + _$_overlayVisibleAtom.reportRead(); + return super._overlayVisible; + } + + @override + bool get _overlayVisible => overlayVisible; + + @override + set _overlayVisible(bool value) { + _$_overlayVisibleAtom.reportWrite(value, super._overlayVisible, () { + super._overlayVisible = value; + }); + } + + late final _$_streamInfoAtom = Atom( + name: 'NativeVideoStoreBase._streamInfo', + context: context, + ); + + StreamTwitch? get streamInfo { + _$_streamInfoAtom.reportRead(); + return super._streamInfo; + } + + @override + StreamTwitch? get _streamInfo => streamInfo; + + @override + set _streamInfo(StreamTwitch? value) { + _$_streamInfoAtom.reportWrite(value, super._streamInfo, () { + super._streamInfo = value; + }); + } + + late final _$_offlineChannelInfoAtom = Atom( + name: 'NativeVideoStoreBase._offlineChannelInfo', + context: context, + ); + + Channel? get offlineChannelInfo { + _$_offlineChannelInfoAtom.reportRead(); + return super._offlineChannelInfo; + } + + @override + Channel? get _offlineChannelInfo => offlineChannelInfo; + + @override + set _offlineChannelInfo(Channel? value) { + _$_offlineChannelInfoAtom.reportWrite(value, super._offlineChannelInfo, () { + super._offlineChannelInfo = value; + }); + } + + late final _$_availableStreamQualitiesAtom = Atom( + name: 'NativeVideoStoreBase._availableStreamQualities', + context: context, + ); + + List get availableStreamQualities { + _$_availableStreamQualitiesAtom.reportRead(); + return super._availableStreamQualities; + } + + @override + List get _availableStreamQualities => availableStreamQualities; + + @override + set _availableStreamQualities(List value) { + _$_availableStreamQualitiesAtom.reportWrite( + value, + super._availableStreamQualities, + () { + super._availableStreamQualities = value; + }, + ); + } + + late final _$_restrictedStreamQualitiesAtom = Atom( + name: 'NativeVideoStoreBase._restrictedStreamQualities', + context: context, + ); + + List get restrictedStreamQualities { + _$_restrictedStreamQualitiesAtom.reportRead(); + return super._restrictedStreamQualities; + } + + @override + List get _restrictedStreamQualities => restrictedStreamQualities; + + @override + set _restrictedStreamQualities(List value) { + _$_restrictedStreamQualitiesAtom.reportWrite( + value, + super._restrictedStreamQualities, + () { + super._restrictedStreamQualities = value; + }, + ); + } + + late final _$_streamQualityIndexAtom = Atom( + name: 'NativeVideoStoreBase._streamQualityIndex', + context: context, + ); + + int get streamQualityIndex { + _$_streamQualityIndexAtom.reportRead(); + return super._streamQualityIndex; + } + + @override + int get _streamQualityIndex => streamQualityIndex; + + @override + set _streamQualityIndex(int value) { + _$_streamQualityIndexAtom.reportWrite(value, super._streamQualityIndex, () { + super._streamQualityIndex = value; + }); + } + + late final _$_latencyAtom = Atom( + name: 'NativeVideoStoreBase._latency', + context: context, + ); + + String? get latency { + _$_latencyAtom.reportRead(); + return super._latency; + } + + @override + String? get _latency => latency; + + @override + set _latency(String? value) { + _$_latencyAtom.reportWrite(value, super._latency, () { + super._latency = value; + }); + } + + late final _$_isInPipModeAtom = Atom( + name: 'NativeVideoStoreBase._isInPipMode', + context: context, + ); + + bool get isInPipMode { + _$_isInPipModeAtom.reportRead(); + return super._isInPipMode; + } + + @override + bool get _isInPipMode => isInPipMode; + + @override + set _isInPipMode(bool value) { + _$_isInPipModeAtom.reportWrite(value, super._isInPipMode, () { + super._isInPipMode = value; + }); + } + + late final _$_isAudioOnlyModeAtom = Atom( + name: 'NativeVideoStoreBase._isAudioOnlyMode', + context: context, + ); + + bool get isAudioOnlyMode { + _$_isAudioOnlyModeAtom.reportRead(); + return super._isAudioOnlyMode; + } + + @override + bool get _isAudioOnlyMode => isAudioOnlyMode; + + @override + set _isAudioOnlyMode(bool value) { + _$_isAudioOnlyModeAtom.reportWrite(value, super._isAudioOnlyMode, () { + super._isAudioOnlyMode = value; + }); + } + + late final _$_errorAtom = Atom( + name: 'NativeVideoStoreBase._error', + context: context, + ); + + String? get error { + _$_errorAtom.reportRead(); + return super._error; + } + + @override + String? get _error => error; + + @override + set _error(String? value) { + _$_errorAtom.reportWrite(value, super._error, () { + super._error = value; + }); + } + + late final _$_initPlayerAsyncAction = AsyncAction( + 'NativeVideoStoreBase._initPlayer', + context: context, + ); + + @override + Future _initPlayer() { + return _$_initPlayerAsyncAction.run(() => super._initPlayer()); + } + + late final _$updateStreamQualitiesAsyncAction = AsyncAction( + 'NativeVideoStoreBase.updateStreamQualities', + context: context, + ); + + @override + Future updateStreamQualities() { + return _$updateStreamQualitiesAsyncAction.run( + () => super.updateStreamQualities(), + ); + } + + late final _$setStreamQualityAsyncAction = AsyncAction( + 'NativeVideoStoreBase.setStreamQuality', + context: context, + ); + + @override + Future setStreamQuality(String quality) { + return _$setStreamQualityAsyncAction.run( + () => super.setStreamQuality(quality), + ); + } + + late final _$_setStreamQualityIndexAsyncAction = AsyncAction( + 'NativeVideoStoreBase._setStreamQualityIndex', + context: context, + ); + + @override + Future _setStreamQualityIndex(int index) { + return _$_setStreamQualityIndexAsyncAction.run( + () => super._setStreamQualityIndex(index), + ); + } + + late final _$updateStreamInfoAsyncAction = AsyncAction( + 'NativeVideoStoreBase.updateStreamInfo', + context: context, + ); + + @override + Future updateStreamInfo({bool forceUpdate = false}) { + return _$updateStreamInfoAsyncAction.run( + () => super.updateStreamInfo(forceUpdate: forceUpdate), + ); + } + + late final _$NativeVideoStoreBaseActionController = ActionController( + name: 'NativeVideoStoreBase', + context: context, + ); + + @override + Future _reloadCurrentController() { + final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction( + name: 'NativeVideoStoreBase._reloadCurrentController', + ); + try { + return super._reloadCurrentController(); + } finally { + _$NativeVideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void handleVideoTap() { + final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction( + name: 'NativeVideoStoreBase.handleVideoTap', + ); + try { + return super.handleVideoTap(); + } finally { + _$NativeVideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void handleToggleOverlay() { + final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction( + name: 'NativeVideoStoreBase.handleToggleOverlay', + ); + try { + return super.handleToggleOverlay(); + } finally { + _$NativeVideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + Future handleRefresh() { + final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction( + name: 'NativeVideoStoreBase.handleRefresh', + ); + try { + return super.handleRefresh(); + } finally { + _$NativeVideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void togglePictureInPicture() { + final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction( + name: 'NativeVideoStoreBase.togglePictureInPicture', + ); + try { + return super.togglePictureInPicture(); + } finally { + _$NativeVideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void handleAppResume() { + final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction( + name: 'NativeVideoStoreBase.handleAppResume', + ); + try { + return super.handleAppResume(); + } finally { + _$NativeVideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void handleAndroidPipChanged(bool isInPip) { + final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction( + name: 'NativeVideoStoreBase.handleAndroidPipChanged', + ); + try { + return super.handleAndroidPipChanged(isInPip); + } finally { + _$NativeVideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void dispose() { + final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction( + name: 'NativeVideoStoreBase.dispose', + ); + try { + return super.dispose(); + } finally { + _$NativeVideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void _resetPlayerStateForChatOnly() { + final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction( + name: 'NativeVideoStoreBase._resetPlayerStateForChatOnly', + ); + try { + return super._resetPlayerStateForChatOnly(); + } finally { + _$NativeVideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void _initFreshController() { + final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction( + name: 'NativeVideoStoreBase._initFreshController', + ); + try { + return super._initFreshController(); + } finally { + _$NativeVideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + String toString() { + return ''' +streamQuality: ${streamQuality} + '''; + } +} diff --git a/lib/screens/channel/video/playback_error_recovery.dart b/lib/screens/channel/video/playback_error_recovery.dart new file mode 100644 index 00000000..72b83815 --- /dev/null +++ b/lib/screens/channel/video/playback_error_recovery.dart @@ -0,0 +1,27 @@ +import 'package:frosty/screens/channel/video/video_timing_constants.dart'; + +/// Whether [NativeVideoStore] should auto-refresh after a mid-stream player +/// error instead of pinning the Reload overlay. +/// +/// ExoPlayer's "Source error" (screen-off / network switch) arrives without a +/// status code, so callers must not rely on 4xx alone. [mediaServicesWereReset] +/// has its own dispose+rebuild path and is excluded here. +bool shouldAutoRecoverFromPlayerError({ + required bool refreshCapReached, + String? errorCode, +}) { + if (refreshCapReached) return false; + if (errorCode == 'mediaServicesWereReset') return false; + return true; +} + +/// Delay before an automatic refresh for [statusCode]. +/// +/// Token/CDN 4xx can refresh immediately; network/source failures get a short +/// pause so the interface can come back after a switch or screen wake. +Duration playerErrorRetryDelay({int? statusCode}) { + if (statusCode != null && statusCode >= 400 && statusCode < 500) { + return Duration.zero; + } + return VideoTimingConstants.initRetryDelay; +} diff --git a/lib/screens/channel/video/stream_info_bar.dart b/lib/screens/channel/video/stream_info_bar.dart new file mode 100644 index 00000000..580acfd0 --- /dev/null +++ b/lib/screens/channel/video/stream_info_bar.dart @@ -0,0 +1,337 @@ +import 'package:flutter/material.dart'; +import 'package:frosty/models/channel.dart'; +import 'package:frosty/models/stream.dart'; +import 'package:frosty/screens/home/top/categories/category_streams.dart'; +import 'package:frosty/theme.dart'; +import 'package:frosty/utils.dart'; +import 'package:frosty/utils/context_extensions.dart'; +import 'package:frosty/widgets/live_indicator.dart'; +import 'package:frosty/widgets/profile_picture.dart'; +import 'package:frosty/widgets/uptime.dart'; +import 'package:intl/intl.dart'; + +class StreamInfoBar extends StatelessWidget { + final StreamTwitch? streamInfo; + final Channel? offlineChannelInfo; + final bool showCategory; + final bool tappableCategory; + final bool showUptime; + final bool showViewerCount; + final bool showOfflineIndicator; + final EdgeInsets padding; + final TooltipTriggerMode tooltipTriggerMode; + final Color? textColor; + final bool isCompact; + final bool isInSharedChatMode; + final bool isOffline; + final bool showTextShadows; + final String? displayName; + + const StreamInfoBar({ + super.key, + this.streamInfo, + this.offlineChannelInfo, + this.showCategory = true, + this.tappableCategory = true, + this.showUptime = true, + this.showViewerCount = true, + this.showOfflineIndicator = true, + this.padding = EdgeInsets.zero, + this.tooltipTriggerMode = TooltipTriggerMode.tap, + this.textColor, + this.isCompact = false, + this.isInSharedChatMode = false, + this.isOffline = false, + this.showTextShadows = true, + this.displayName, + }); + + + TextStyle _getBaseTextStyle( + BuildContext context, + double fontSize, + FontWeight fontWeight, + ) { + return context.textTheme.bodyMedium?.copyWith( + fontSize: fontSize, + fontWeight: fontWeight, + color: textColor, + shadows: showTextShadows ? kOverlayShadow : null, + ) ?? + const TextStyle(); + } + + TextStyle _getSecondaryTextStyle(BuildContext context, double fontSize) { + return context.textTheme.bodyMedium?.copyWith( + fontSize: fontSize, + fontWeight: FontWeight.w500, + color: + textColor?.withValues(alpha: 0.7) ?? + context.bodySmallColor?.withValues(alpha: 0.7), + shadows: showTextShadows ? kOverlayShadow : null, + ) ?? + const TextStyle(); + } + + @override + Widget build(BuildContext context) { + final streamTitle = isOffline + ? (offlineChannelInfo?.title.trim() ?? '') + : (streamInfo?.title.trim() ?? ''); + final streamerName = isOffline + ? getReadableName( + offlineChannelInfo?.broadcasterName.isNotEmpty == true + ? offlineChannelInfo?.broadcasterName ?? '' + : displayName ?? '', + offlineChannelInfo?.broadcasterLogin ?? '', + ) + : getReadableName( + streamInfo?.userName ?? '', + streamInfo?.userLogin ?? '', + ); + final secondLineSize = isCompact ? 13.0 : 14.0; + + return Padding( + padding: padding, + child: Row( + spacing: 8, + children: [ + Container( + decoration: isInSharedChatMode + ? BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).colorScheme.primary, + width: 2, + ), + ) + : null, + child: Padding( + padding: isInSharedChatMode + ? const EdgeInsets.all(1.5) + : EdgeInsets.zero, + child: ProfilePicture( + userLogin: isOffline + ? (offlineChannelInfo?.broadcasterLogin.isNotEmpty == true + ? offlineChannelInfo?.broadcasterLogin ?? '' + : displayName ?? '') + : (streamInfo?.userLogin ?? ''), + radius: 16, + ), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Top row: Streamer name + stream title + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + spacing: 4, + children: [ + Tooltip( + message: streamerName, + triggerMode: tooltipTriggerMode, + child: Text( + streamerName, + style: _getBaseTextStyle(context, 14, FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + ), + if (streamTitle.isNotEmpty) ...[ + Flexible( + child: Tooltip( + message: streamTitle, + triggerMode: tooltipTriggerMode, + child: Text( + streamTitle, + style: _getSecondaryTextStyle(context, 14), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ), + ], + ], + ), + // Bottom row: Live indicator, uptime, viewer count, game name or Offline text + if (isOffline || + (!isOffline && showUptime) || + (!isOffline && showViewerCount) || + (showCategory && + (isOffline + ? (offlineChannelInfo?.gameName.isNotEmpty ?? false) + : (streamInfo?.gameName.isNotEmpty ?? false)))) ...[ + Row( + children: [ + if (isOffline && showOfflineIndicator) ...[ + Text( + 'Offline', + style: _getSecondaryTextStyle( + context, + secondLineSize, + ), + ), + ], + if (isOffline && + showCategory && + (offlineChannelInfo?.gameName.isNotEmpty ?? + false)) ...[ + if (showOfflineIndicator) const SizedBox(width: 8), + Icon( + Icons.gamepad, + size: secondLineSize, + color: (textColor ?? context.bodySmallColor) + ?.withValues(alpha: 0.7), + shadows: kOverlayShadow, + ), + const SizedBox(width: 4), + Flexible( + child: Tooltip( + message: offlineChannelInfo?.gameName ?? '', + triggerMode: tooltipTriggerMode, + child: tappableCategory + ? GestureDetector( + onDoubleTap: () { + if (offlineChannelInfo + ?.gameId + .isNotEmpty ?? + false) { + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: CategoryStreams.routeName), + builder: (context) => + CategoryStreams( + categoryId: + offlineChannelInfo + ?.gameId ?? + '', + ), + ), + ); + } + }, + child: Text( + offlineChannelInfo?.gameName ?? '', + style: _getSecondaryTextStyle( + context, + secondLineSize, + ), + overflow: TextOverflow.ellipsis, + ), + ) + : Text( + offlineChannelInfo?.gameName ?? '', + style: _getSecondaryTextStyle( + context, + secondLineSize, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ] else ...[ + if (!isOffline && (showUptime || showViewerCount)) ...[ + const LiveIndicator(), + const SizedBox(width: 6), + ], + if (!isOffline && showUptime) ...[ + Uptime( + startTime: + streamInfo?.startedAt ?? + DateTime.now().toIso8601String(), + style: _getBaseTextStyle( + context, + secondLineSize, + FontWeight.w500, + ), + ), + if (!isOffline && showViewerCount) + const SizedBox(width: 8), + ], + if (!isOffline && showViewerCount) ...[ + Icon( + Icons.visibility, + size: secondLineSize, + color: textColor ?? context.bodySmallColor, + shadows: kOverlayShadow, + ), + const SizedBox(width: 4), + Text( + NumberFormat().format(streamInfo?.viewerCount ?? 0), + style: _getBaseTextStyle( + context, + secondLineSize, + FontWeight.w500, + ), + ), + ], + if (!isOffline && + showCategory && + (streamInfo?.gameName.isNotEmpty ?? false) && + (showUptime || showViewerCount)) ...[ + const SizedBox(width: 8), + ], + if (!isOffline && + showCategory && + (streamInfo?.gameName.isNotEmpty ?? false)) ...[ + Icon( + Icons.gamepad, + size: secondLineSize, + color: textColor ?? context.bodySmallColor, + shadows: kOverlayShadow, + ), + const SizedBox(width: 4), + Flexible( + child: Tooltip( + message: streamInfo?.gameName ?? '', + triggerMode: tooltipTriggerMode, + child: tappableCategory + ? GestureDetector( + onDoubleTap: () => Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: CategoryStreams.routeName), + builder: (context) => CategoryStreams( + categoryId: + streamInfo?.gameId ?? '', + ), + ), + ), + child: Text( + streamInfo?.gameName ?? '', + style: _getBaseTextStyle( + context, + secondLineSize, + FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ) + : Text( + streamInfo?.gameName ?? '', + style: _getBaseTextStyle( + context, + secondLineSize, + FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ], + ], + ), + ], + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/channel/video/stream_info_poller.dart b/lib/screens/channel/video/stream_info_poller.dart new file mode 100644 index 00000000..5917ffed --- /dev/null +++ b/lib/screens/channel/video/stream_info_poller.dart @@ -0,0 +1,86 @@ +import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/models/channel.dart'; +import 'package:frosty/models/stream.dart'; +import 'package:frosty/screens/channel/video/video_timing_constants.dart'; + +/// Snapshot of a channel's live state. +class StreamInfoResult { + const StreamInfoResult({this.stream, this.offlineChannel}); + + /// Non-null when the channel is currently live. + final StreamTwitch? stream; + + /// Non-null when the channel is confirmed offline (channel metadata fetched + /// successfully but no live stream). Both fields null means the fetch + /// failed entirely (network error, etc.). + final Channel? offlineChannel; +} + +/// Debounced, deduplicated Twitch stream-info fetcher shared by both video +/// stores. Each store still owns its observable `_streamInfo` / +/// `_offlineChannelInfo` fields and decides how to react to the result. +class StreamInfoPoller { + StreamInfoPoller({ + required this.twitchApi, + required this.userLogin, + required this.userId, + }); + + final TwitchApi twitchApi; + final String userLogin; + final String userId; + + DateTime? _lastUpdate; + Future? _inFlight; + + /// Fetches the latest stream info, returning `null` if the call was + /// debounced (caller should skip updating state). Concurrent calls await + /// the in-flight request instead of issuing a new one. + Future fetch({bool forceUpdate = false}) async { + final pending = _inFlight; + if (pending != null) return pending; + + final now = DateTime.now(); + if (!forceUpdate && _lastUpdate != null) { + if (now.difference(_lastUpdate!) < + VideoTimingConstants.streamInfoDebounce) { + return null; + } + } + + _lastUpdate = now; + final request = _runFetch(); + _inFlight = request; + try { + return await request; + } finally { + if (identical(_inFlight, request)) _inFlight = null; + } + } + + Future _runFetch() async { + try { + final info = await twitchApi.getStream(userLogin: userLogin); + return StreamInfoResult(stream: info); + } catch (_) { + // Helix can transiently drop a live stream from /streams (or 500 on + // one endpoint while others succeed). A false "confirmed offline" + // tears down latency polling and chat delay on healthy playback, so + // require a second failed check before falling back to /channels. + try { + await Future.delayed( + VideoTimingConstants.offlineConfirmationDelay, + ); + final info = await twitchApi.getStream(userLogin: userLogin); + return StreamInfoResult(stream: info); + } catch (_) { + try { + final channel = await twitchApi.getChannel(userId: userId); + return StreamInfoResult(offlineChannel: channel); + } catch (_) { + return const StreamInfoResult(); + } + } + } + } +} diff --git a/lib/screens/channel/video/video.dart b/lib/screens/channel/video/video.dart index 60ff16ce..fa9cc703 100644 --- a/lib/screens/channel/video/video.dart +++ b/lib/screens/channel/video/video.dart @@ -4,15 +4,13 @@ import 'package:flutter/material.dart'; import 'package:frosty/screens/channel/video/video_store.dart'; import 'package:simple_pip_mode/simple_pip.dart'; import 'package:webview_flutter/webview_flutter.dart'; +import 'package:webview_flutter_android/webview_flutter_android.dart'; /// Creates a [WebView] widget that shows a channel's video stream. class Video extends StatefulWidget { final VideoStore videoStore; - const Video({ - Key? key, - required this.videoStore, - }) : super(key: key); + const Video({super.key, required this.videoStore}); @override State