diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..dba939da --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "enabledPlugins": { + "frontend-design@claude-plugins-official": true, + "feature-dev@claude-plugins-official": true, + "commit-commands@claude-plugins-official": true, + "pr-review-toolkit@claude-plugins-official": true, + "code-simplifier@claude-plugins-official": true, + "typescript-lsp@claude-plugins-official": true, + "claude-md-management@claude-plugins-official": true, + "claude-code-setup@claude-plugins-official": true + }, + "enabledMcpjsonServers": [ + "next-devtools" + ], + "outputStyle": "Explanatory" +} diff --git a/.cursor/mcp.json b/.cursor/mcp.json deleted file mode 100644 index 0127976e..00000000 --- a/.cursor/mcp.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "mcpServers": { - "dart": { - "command": "dart", - "args": ["mcp-server", "--force-roots-fallback"] - }, - "next-devtools": { - "command": "node", - "args": ["web/node_modules/next-devtools-mcp/dist/index.js"] - } - } -} diff --git a/.env.example b/.env.example deleted file mode 100644 index 4d4439a4..00000000 --- a/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -CLIENT_ID= -SECRET= diff --git a/.forgejo/workflows/actions-probe.yml b/.forgejo/workflows/actions-probe.yml deleted file mode 100644 index a9e9414d..00000000 --- a/.forgejo/workflows/actions-probe.yml +++ /dev/null @@ -1,14 +0,0 @@ -# Temporary probe: does this repo schedule Actions at all? -name: Actions probe - -on: - push: - branches: [main] - workflow_dispatch: - -jobs: - probe: - runs-on: desktop-release - steps: - - name: ping - run: echo "frosty actions probe ok" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml deleted file mode 100644 index 8e8ac8fd..00000000 --- a/.forgejo/workflows/release.yml +++ /dev/null @@ -1,304 +0,0 @@ -# Builds a signed Frosty release APK and publishes a Forgejo release for Obtainium. -# Pattern: finamp-fork / jellyfin-android-fork on host runner `desktop-release`. -# -# Required repository secrets: -# RELEASE_KEYSTORE_BASE64, STORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD -# CLIENT_ID, SECRET (Twitch Helix app credentials — --dart-define) -# -# Obtainium: -# Source: Forgejo -# URL: https://forgejo.fsociety00.cc/api/v1/repos/copilot/frosty/releases -# APK filter: frosty-arm64-v8a-v.*\.apk -# Trim version: v?([0-9]+\.[0-9]+\.[0-9]+) -# Match group: 1 -# Private repo → Forgejo API token in Obtainium. - -name: Release APK - -on: - push: - tags: - - 'v*' - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: frosty-release - cancel-in-progress: true - -jobs: - build-and-release: - name: Build & publish release - runs-on: desktop-release - - steps: - - name: Clone repository - shell: bash - env: - FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - rm -rf .git - git init . - origin_url="${GITHUB_SERVER_URL#https://}" - origin_url="${origin_url#http://}" - git remote add origin "https://copilot:${FORGEJO_TOKEN}@${origin_url}/${GITHUB_REPOSITORY}.git" - git -c http.sslVerify=false fetch --depth=1 origin "${GITHUB_SHA}" - git checkout --force FETCH_HEAD - test -f pubspec.yaml - test -f android/app/build.gradle.kts - echo "Checked out ${GITHUB_REPOSITORY}@${GITHUB_SHA}" - - - name: Set up JDK - shell: bash - run: | - set -euo pipefail - pkg_install() { - if [ "$(id -u)" -eq 0 ]; then "$@" - elif command -v sudo >/dev/null 2>&1; then sudo "$@" - else "$@"; fi - } - if ! command -v java >/dev/null 2>&1; then - if command -v dnf >/dev/null 2>&1; then pkg_install dnf install -y java-21-openjdk-devel - elif command -v apt-get >/dev/null 2>&1; then - pkg_install apt-get update - pkg_install apt-get install -y openjdk-21-jdk - elif command -v apk >/dev/null 2>&1; then pkg_install apk add --no-cache openjdk21 - fi - fi - java -version - if command -v javac >/dev/null 2>&1; then - java_home="$(dirname "$(dirname "$(readlink -f "$(command -v javac)")")")" - echo "JAVA_HOME=$java_home" >> "$GITHUB_ENV" - fi - - - name: Set up Android SDK - shell: bash - run: | - set -euo pipefail - sdk_candidates=( - "${ANDROID_HOME:-}" - "${ANDROID_SDK_ROOT:-}" - "/home/yuna/Android/Sdk" - "${HOME}/Android/Sdk" - "/opt/android-sdk-linux" - "/root/Android/Sdk" - ) - sdk_dir="" - for candidate in "${sdk_candidates[@]}"; do - if [ -n "$candidate" ] && [ -d "$candidate/platforms" ] && [ -d "$candidate/build-tools" ]; then - sdk_dir="$candidate" - break - fi - done - if [ -z "$sdk_dir" ]; then - echo "Android SDK not found on desktop-release host." - exit 1 - fi - echo "ANDROID_HOME=$sdk_dir" >> "$GITHUB_ENV" - echo "ANDROID_SDK_ROOT=$sdk_dir" >> "$GITHUB_ENV" - printf 'sdk.dir=%s\n' "$sdk_dir" > android/local.properties - echo "Using Android SDK: $sdk_dir" - - - name: Set up Flutter - shell: bash - run: | - set -euo pipefail - pkg_install() { - if [ "$(id -u)" -eq 0 ]; then "$@" - elif command -v sudo >/dev/null 2>&1; then sudo "$@" - else "$@"; fi - } - if ! command -v curl >/dev/null 2>&1; then - if command -v apk >/dev/null 2>&1; then pkg_install apk add --no-cache curl bash git unzip xz - elif command -v dnf >/dev/null 2>&1; then pkg_install dnf install -y curl git unzip - elif command -v apt-get >/dev/null 2>&1; then - pkg_install apt-get update - pkg_install apt-get install -y curl git unzip - fi - fi - # Prefer pinned SDK already on the desktop-release host. - flutter_version="$(awk '/^[[:space:]]*flutter:/{print $2; exit}' pubspec.yaml)" - flutter_version="${flutter_version:-3.47.0}" - candidates=( - "/home/yuna/.local/flutter-${flutter_version}" - "${HOME}/.cache/frosty-ci-flutter-${flutter_version}" - "/home/yuna/.local/flutter" - ) - flutter_home="" - for c in "${candidates[@]}"; do - if [ -x "${c}/bin/flutter" ]; then - flutter_home="$c" - break - fi - done - if [ -z "$flutter_home" ]; then - flutter_home="${HOME}/.cache/frosty-ci-flutter-${flutter_version}" - rm -rf "${flutter_home}" - git clone --depth 1 --branch "${flutter_version}" https://github.com/flutter/flutter.git "${flutter_home}" - fi - echo "${flutter_home}/bin" >> "$GITHUB_PATH" - echo "PATH=${flutter_home}/bin:${PATH}" >> "$GITHUB_ENV" - export PATH="${flutter_home}/bin:${PATH}" - flutter --version - flutter precache --android - - - name: Decode keystore - shell: bash - env: - RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} - STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }} - KEY_ALIAS: ${{ secrets.KEY_ALIAS }} - KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} - run: | - set -euo pipefail - if [ -z "${RELEASE_KEYSTORE_BASE64:-}" ] || [ -z "${STORE_PASSWORD:-}" ] || [ -z "${KEY_ALIAS:-}" ] || [ -z "${KEY_PASSWORD:-}" ]; then - echo "Missing signing secrets: RELEASE_KEYSTORE_BASE64, STORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD" - exit 1 - fi - printf '%s' "$RELEASE_KEYSTORE_BASE64" | base64 -d > android/upload-keystore.jks - test -s android/upload-keystore.jks - { - echo "storePassword=${STORE_PASSWORD}" - echo "keyPassword=${KEY_PASSWORD}" - echo "keyAlias=${KEY_ALIAS}" - echo "storeFile=${GITHUB_WORKSPACE}/android/upload-keystore.jks" - } > android/key.properties - echo "Keystore ready ($(wc -c < android/upload-keystore.jks) bytes)." - - - name: Resolve version metadata - id: version - shell: bash - run: | - set -euo pipefail - raw="$(grep -E '^version:' pubspec.yaml | head -1 | awk '{print $2}')" - VERSION_NAME="${raw%%+*}" - VERSION_CODE="${raw##*+}" - [ "$VERSION_CODE" != "$raw" ] || VERSION_CODE=1 - SHORT_SHA="${GITHUB_SHA::7}" - if [[ "${GITHUB_REF}" == refs/tags/* ]]; then - TAG="${GITHUB_REF#refs/tags/}" - else - TAG="build-${VERSION_NAME}-${VERSION_CODE}-${SHORT_SHA}" - fi - echo "version_name=${VERSION_NAME}" >> "$GITHUB_OUTPUT" - echo "version_code=${VERSION_CODE}" >> "$GITHUB_OUTPUT" - echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" - echo "tag=${TAG}" >> "$GITHUB_OUTPUT" - echo "tag=${TAG} version=${VERSION_NAME}+${VERSION_CODE}" - - - name: Build signed release APK - id: build - shell: bash - env: - CLIENT_ID: ${{ secrets.CLIENT_ID }} - SECRET: ${{ secrets.SECRET }} - VERSION_NAME: ${{ steps.version.outputs.version_name }} - VERSION_CODE: ${{ steps.version.outputs.version_code }} - run: | - set -euo pipefail - if [ -z "${CLIENT_ID:-}" ] || [ -z "${SECRET:-}" ]; then - echo "Missing Twitch secrets CLIENT_ID / SECRET (Helix OAuth app)." - exit 1 - fi - flutter pub get - flutter build apk --release \ - --build-name="${VERSION_NAME}" \ - --build-number="${VERSION_CODE}" \ - --dart-define="CLIENT_ID=${CLIENT_ID}" \ - --dart-define="SECRET=${SECRET}" - mkdir -p dist - cp build/app/outputs/flutter-apk/app-release.apk \ - "dist/frosty-v${VERSION_NAME}.apk" - flutter build apk --release --split-per-abi \ - --build-name="${VERSION_NAME}" \ - --build-number="${VERSION_CODE}" \ - --dart-define="CLIENT_ID=${CLIENT_ID}" \ - --dart-define="SECRET=${SECRET}" || true - for abi in arm64-v8a armeabi-v7a x86_64; do - src="build/app/outputs/flutter-apk/app-${abi}-release.apk" - if [ -f "$src" ]; then - cp "$src" "dist/frosty-${abi}-v${VERSION_NAME}.apk" - fi - done - ls -lh dist/ - - - name: Publish Forgejo release - shell: bash - env: - FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ steps.version.outputs.tag }} - VERSION_NAME: ${{ steps.version.outputs.version_name }} - VERSION_CODE: ${{ steps.version.outputs.version_code }} - SHORT_SHA: ${{ steps.version.outputs.short_sha }} - run: | - set -euo pipefail - pkg_install() { - if [ "$(id -u)" -eq 0 ]; then "$@" - elif command -v sudo >/dev/null 2>&1; then sudo "$@" - else return 1; fi - } - for pkg in curl jq; do - command -v "$pkg" >/dev/null 2>&1 && continue - if command -v apk >/dev/null 2>&1; then pkg_install apk add --no-cache "$pkg" - elif command -v dnf >/dev/null 2>&1; then pkg_install dnf install -y "$pkg" - elif command -v apt-get >/dev/null 2>&1; then - pkg_install apt-get update - pkg_install apt-get install -y "$pkg" - fi - done - - API="${GITHUB_API_URL:-${GITHUB_SERVER_URL}/api/v1}/repos/${GITHUB_REPOSITORY}" - AUTH="Authorization: token ${FORGEJO_TOKEN}" - - BODY_FILE="$(mktemp)" - cat > "${BODY_FILE}" </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/workflows/ci.yml b/.github/workflows/ci.yml index d6c5c96b..d49bfab7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,57 +3,18 @@ on: push: branches: [main] jobs: - # Linux runner: analyze + test once (both are OS-independent) and compile a - # release APK to catch release-only breakage. Debug-signed via the build.gradle - # fallback (ALLOW_DEBUG_SIGNED_RELEASE) — no signing secrets needed. - android: - name: Analyze, test, and build Android - runs-on: ubuntu-latest + test: + name: Check and test on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] steps: - uses: actions/checkout@v6 - # Match the JDK the release workflows build with, so this compile check - # faithfully catches what would break a real release. - - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: 21 - uses: subosito/flutter-action@v2 with: - flutter-version-file: pubspec.yaml cache: true - run: flutter doctor -v - run: flutter pub get - # Fail if committed generated files (*.g.dart) are stale — CI never runs - # build_runner otherwise, so forgetting to regenerate after editing a MobX - # store or @JsonSerializable model slips past analyze/test/build. - - run: dart run build_runner build --delete-conflicting-outputs - - name: Verify generated files are up to date - run: | - if [ -n "$(git status --porcelain)" ]; then - echo '::error::Generated files are out of date. Run `dart run build_runner build --delete-conflicting-outputs` and commit the result.' - git status --porcelain - git diff - exit 1 - fi - run: flutter analyze - run: flutter test - - run: flutter build apk --release - env: - ALLOW_DEBUG_SIGNED_RELEASE: '1' - # macOS runner: release-compile iOS (no codesign, no secrets) — the only thing - # that must run on macOS. analyze/test already covered on the Linux job. - ios: - name: Build iOS - runs-on: macos-latest - steps: - - uses: actions/checkout@v6 - # Match release workflows and avoid GitHub's lagging default Xcode. - - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: latest-stable - - uses: subosito/flutter-action@v2 - with: - flutter-version-file: pubspec.yaml - cache: true - - run: flutter pub get - - run: flutter build ios --release --no-codesign diff --git a/.github/workflows/gh-release.yml b/.github/workflows/gh-release.yml index a0d23fd7..1846001d 100644 --- a/.github/workflows/gh-release.yml +++ b/.github/workflows/gh-release.yml @@ -32,7 +32,6 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version-file: pubspec.yaml cache: true # Decode and create the upload-keystore.jks file, used for signing the app. @@ -97,7 +96,7 @@ jobs: # Create a draft release on GitHub. - name: Release - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@v2 with: draft: true generate_release_notes: ${{ steps.release_notes.outputs.found != 'true' }} diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 1c3e9897..5a454846 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -8,36 +8,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - # Match the JDK the release workflows build with, so the release-mode APK - # check below faithfully catches what would break a real release. - - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: 21 - uses: subosito/flutter-action@v2 with: - flutter-version-file: pubspec.yaml cache: true - run: flutter doctor -v - run: flutter pub get - # Fail if committed generated files (*.g.dart) are stale — CI never runs - # build_runner otherwise, so forgetting to regenerate after editing a MobX - # store or @JsonSerializable model slips past analyze/test/build. - - run: dart run build_runner build --delete-conflicting-outputs - - name: Verify generated files are up to date - run: | - if [ -n "$(git status --porcelain)" ]; then - echo '::error::Generated files are out of date. Run `dart run build_runner build --delete-conflicting-outputs` and commit the result.' - git status --porcelain - git diff - exit 1 - fi - run: flutter analyze - run: flutter test - # Release-mode compile check (no signing secrets needed — Android falls back - # to debug signing via ALLOW_DEBUG_SIGNED_RELEASE). Catches release-only - # breakage that debug builds and tests miss (AOT compile, icon tree-shaking, - # plugin/Gradle release incompatibility). - - run: flutter build apk --release - env: - ALLOW_DEBUG_SIGNED_RELEASE: '1' diff --git a/.github/workflows/release-dryrun.yml b/.github/workflows/release-dryrun.yml deleted file mode 100644 index 0b905936..00000000 --- a/.github/workflows/release-dryrun.yml +++ /dev/null @@ -1,126 +0,0 @@ -# Dry-runs the full store release pipeline without publishing, to catch -# credential, signing, dependency, and store-validation breakage before an -# actual release. Mirrors store-release.yml but stops short of publishing: -# Android validates the AAB against Google Play (validate_only); iOS builds a -# fully signed archive via match (readonly) and skips the App Store upload. -name: Release dry-run -on: - # Run on demand before cutting a release. - workflow_dispatch: - # Monthly heartbeat so cert expiry, secret rotation, and dependency drift - # surface on their own between releases. - schedule: - - cron: '0 9 1 * *' # 1st of the month, 09:00 UTC -# Setup environment variables for use in all jobs. -env: - # Gemfile path used for caching dependencies. - BUNDLE_GEMFILE: ${{ github.workspace }}/android/Gemfile - CLIENT_ID: ${{ secrets.CLIENT_ID }} - SECRET: ${{ secrets.SECRET }} - LC_ALL: en_US.UTF-8 - LANG: en_US.UTF-8 -jobs: - android: - name: Dry-run Android release - runs-on: ubuntu-latest - environment: release-android - env: - UPLOAD_KEYSTORE: ${{ secrets.UPLOAD_KEYSTORE }} - STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }} - KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} - KEY_FILE_PATH: android/key.properties - PLAY_STORE_CREDENTIALS: ${{ secrets.PLAY_STORE_CREDENTIALS }} - - steps: - - name: Checkout Frosty repo - uses: actions/checkout@v6 - - # Install Ruby and install/cache dependencies with bundler in order to use fastlane. - - name: Set up Ruby and Bundler - uses: ruby/setup-ruby@v1 - with: - ruby-version: 3.3 - bundler-cache: true - - # Setup Java so that we can build the appbundle. - - name: Set up Java - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: 21 - - # Setup and cache Flutter to install packages and build. - - name: Set up Flutter - uses: subosito/flutter-action@v2 - with: - flutter-version-file: pubspec.yaml - cache: true - - # Decode and create the upload-keystore.jks file, used for signing the app. - - name: Decode and create upload-keystore.jks - run: echo $UPLOAD_KEYSTORE | base64 --decode > $GITHUB_WORKSPACE/android/upload-keystore.jks - - # Create the key.properties file, used for signing the app. - - name: Create key.properties - run: | - echo storePassword=$STORE_PASSWORD >> $KEY_FILE_PATH - echo keyPassword=$KEY_PASSWORD >> $KEY_FILE_PATH - echo keyAlias=upload >> $KEY_FILE_PATH - echo storeFile=$GITHUB_WORKSPACE/android/upload-keystore.jks >> $KEY_FILE_PATH - - # Build the Android app bundle (AAB) and pass the environment variables. - # - # The version code is overridden because Play rejects one it has already - # seen, and pubspec's code is by definition already published between - # releases — so the dry-run would fail on every scheduled run and never - # reach the validation it exists to perform. Epoch seconds is always - # unused, always increasing, and well under Play's 2100000000 cap. The - # bundle is only ever validated, never published, so the value is thrown - # away. versionName still comes from pubspec. - - name: Build the Android AAB - run: flutter build appbundle --build-number=$(date +%s) --dart-define CLIENT_ID=$CLIENT_ID --dart-define SECRET=$SECRET - - # Validate the AAB and credentials against Google Play without publishing. - - name: Validate release with fastlane (no publish) - run: cd android && bundle exec fastlane deploy dry_run:true - ios: - name: Dry-run iOS release - runs-on: macos-latest - environment: release-ios - env: - APP_STORE_KEY_ID: ${{ secrets.APP_STORE_KEY_ID }} - APP_STORE_ISSUER_ID: ${{ secrets.APP_STORE_ISSUER_ID }} - APP_STORE_CREDENTIALS: ${{ secrets.APP_STORE_CREDENTIALS }} - MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }} - MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} - steps: - - name: Checkout Frosty repo - uses: actions/checkout@v6 - - # Always pick the newest production Xcode on the runner. - - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: latest-stable - - # Install Ruby and install/cache dependencies with bundler in order to use fastlane. - - name: Set up Ruby and Bundler - uses: ruby/setup-ruby@v1 - with: - ruby-version: 3.3 - bundler-cache: true - - # Setup and cache Flutter to install packages and build. - - name: Set up Flutter - uses: subosito/flutter-action@v2 - with: - flutter-version-file: pubspec.yaml - cache: true - - # Setup the iOS project with environment variables without signing (building and signing will happen in fastlane). - - name: Config the iOS build - run: flutter build ios --no-codesign --config-only --dart-define CLIENT_ID=$CLIENT_ID --dart-define SECRET=$SECRET - - # Authenticate ASC API key, resolve signing via match (readonly), and build a - # fully signed archive without uploading to the App Store. - - name: Sign and build with fastlane (no publish) - run: cd ios && bundle exec fastlane release dry_run:true diff --git a/.github/workflows/store-release.yml b/.github/workflows/store-release.yml index 53365a9e..75afb083 100644 --- a/.github/workflows/store-release.yml +++ b/.github/workflows/store-release.yml @@ -35,7 +35,7 @@ jobs: - name: Set up Ruby and Bundler uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3 + ruby-version: 3.2 bundler-cache: true # Setup Java so that we can build the appbundle. @@ -49,7 +49,6 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version-file: pubspec.yaml cache: true # Decode and create the upload-keystore.jks file, used for signing the app. @@ -85,7 +84,7 @@ jobs: - name: Checkout Frosty repo uses: actions/checkout@v6 - # Always pick the newest production Xcode on the runner. + # Always pick the newest production Xcode on the runner - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: latest-stable @@ -94,14 +93,13 @@ jobs: - name: Set up Ruby and Bundler uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3 + ruby-version: 3.2 bundler-cache: true # Setup and cache Flutter to install packages and build. - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version-file: pubspec.yaml cache: true # Setup the iOS project with environment variables without signing (building and signing will happen in fastlane). diff --git a/.gitignore b/.gitignore index dfee3eb8..bb531e6b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,7 @@ migrate_working_dir/ # The .vscode folder contains launch configuration and tasks you configure in # VS Code which you may wish to be included in version control, so this line # is commented out by default. -# .vscode/ +.vscode/ # Flutter/Dart/Pub related **/doc/api/ @@ -44,8 +44,6 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release -# AGP 9 writes a problems report here even when the Gradle build dir is redirected -/android/build/ # Folder containing random testing files random/ @@ -66,8 +64,5 @@ random/ # 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 index 0127976e..914a959a 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,9 +1,5 @@ { "mcpServers": { - "dart": { - "command": "dart", - "args": ["mcp-server", "--force-roots-fallback"] - }, "next-devtools": { "command": "node", "args": ["web/node_modules/next-devtools-mcp/dist/index.js"] diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index dad4cfce..00000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "name": "frosty", - "request": "launch", - "type": "dart", - "args": ["--dart-define-from-file=.env"] - }, - { - "name": "frosty (profile mode)", - "request": "launch", - "type": "dart", - "flutterMode": "profile", - "args": ["--dart-define-from-file=.env"] - }, - { - "name": "frosty (release mode)", - "request": "launch", - "type": "dart", - "flutterMode": "release", - "args": ["--dart-define-from-file=.env"] - } - ] -} diff --git a/AGENTS.md b/AGENTS.md index 4495eb73..7c1d17db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,46 +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`. +## Workflow + +- In plan mode, interview thoroughly — ask about UI/UX, tradeoffs, and edge cases before coding +- When new code supersedes existing functionality, find and remove everything it makes redundant +- Favor parallel tool calls and subagents when tasks are independent ## Commands ```bash -flutter pub get -flutter run --dart-define-from-file=.env # local; .env is gitignored -flutter run --dart-define=CLIENT_ID=ID --dart-define=SECRET=SECRET -flutter analyze -flutter test -flutter test test/path/to/file.dart -dart run build_runner build # after MobX / @JsonSerializable changes +flutter pub get # Install dependencies +flutter run --dart-define=clientId=ID --dart-define=secret=SECRET # Run with Twitch credentials +flutter analyze # Static analysis (run after changes) +flutter test # Run tests (if changes touch testable logic) +dart run build_runner build # Regenerate .g.dart files (MobX/JSON models) +dart run build_runner build --delete-conflicting-outputs # Same, but clears stale outputs ``` -`--delete-conflicting-outputs` is ignored as of build_runner 2.15. Don't add it. +## Source Structure -## Layout +- `lib/apis/` — API services (all extend `BaseApiClient`) +- `lib/models/` — Data models (`@JsonSerializable` with `.g.dart` codegen) +- `lib/screens/{feature}/` — Feature screens with co-located `stores/` subdirectories +- `lib/stores/` — Global stores only (e.g., `global_assets_store.dart`) +- `lib/services/` — App-level services +- `lib/widgets/` — Shared widgets +- `lib/utils/` — Utility functions -- `lib/apis/` — API clients extend `BaseApiClient` -- `lib/models/` — data models; `@JsonSerializable` ones commit `.g.dart` -- `lib/screens/{feature}/` — screens; feature stores in co-located `stores/` -- `lib/stores/` — global stores only (`global_assets_store.dart`) -- `test/` mirrors `lib/`; shared fixtures in `test/fixtures/` +## Architecture (non-obvious bits) -Don't add top-level store files or standalone API classes. Canonical MobX store: `lib/screens/settings/stores/auth_store.dart`. Extract state, reactions, and actions into stores when it keeps widgets on rendering. - -New or changed logic (stores, API clients, parsing, utils) ships with tests; extend existing tests when touching tested code. Pure UI or config may skip. HTTP: `http_mock_adapter` (`DioAdapter`) with full URLs. Otherwise `mocktail`. +- Feature stores live in `lib/screens/{feature}/stores/` — don't create new top-level store files (only `lib/stores/` holds global stores) +- All API services extend `BaseApiClient` (`lib/apis/base_api_client.dart`) — don't create standalone API classes +- MobX stores use a generated mixin pattern — see `lib/screens/settings/stores/auth_store.dart` for the canonical example ## Gotchas -- Commit regenerated `.g.dart` files. Never edit them. CI fails the PR if they're stale. -- `main.dart` secure-storage `deleteAll()` looks redundant; it handles Android/iOS storage surviving uninstall. Don't remove it. -- `flutter_secure_storage` stays on v9 (v10 shipped in 5.0.0 and was reverted in 5.0.1 over an Android 14 startup freeze). That also holds `package_info_plus` v9 and `device_info_plus` v12 (win32). Don't jump them in a drive-by upgrade. -- Git-pinned forks in `pubspec.yaml` (`extended_text_field`, `better_native_video_player`, `simple_pip_mode`) carry patches. Don't swap them back to pub.dev. -- `android/gradle.properties`: keep `android.builtInKotlin=false` and `android.newDsl=false` so plugins that still apply KGP build on AGP 9. -- `ios/Runner/AppDelegate.swift` is customized (`CookieExtractorPlugin` + `FlutterImplicitEngineDelegate`). Flutter will not auto-migrate UIScene; don't replace the delegate with the template. -- `android/.../MainActivity.kt` extends `PipCallbackHelperActivityWrapper` and registers `CookieExtractorPlugin` by hand. Don't revert it to `FlutterActivity`. -- Helix OAuth (`Bearer`) and the Twitch web GQL cookie are different tokens. Native playback uses the cookie; don't send the Helix token to GQL/Usher. -- `syncedChatDelay` is runtime-only (`includeFromJson/toJson: false`). Don't persist it with the other settings. -- Don't remove the `ALLOW_DEBUG_SIGNED_RELEASE` branch in `android/app/build.gradle.kts`. Real releases must still fail if `key.properties` is missing. +- After changing MobX stores or `@JsonSerializable` models, regenerate with `dart run build_runner build`. Never edit `.g.dart` files directly. Commit `.g.dart` files to source control. +- The secure storage cleanup in `main.dart` looks unnecessary but handles an Android/iOS edge case where secure storage persists after uninstall. Don't remove it. +- Use package imports (`import 'package:frosty/...'`), not relative imports +- Always include trailing commas +- Use single quotes -## Releases +## Commits -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. +Lowercase, no prefixes (e.g., `fix landscape bottom padding in chat bottom bar`). Keep commits tightly scoped. diff --git a/DESIGN.md b/DESIGN.md deleted file mode 100644 index 07a0fe02..00000000 --- a/DESIGN.md +++ /dev/null @@ -1,61 +0,0 @@ -# Frosty fork — Twitch ad blocking (DESIGN) - -Personal Forgejo fork of [tommyxchow/frosty](https://github.com/tommyxchow/frosty) that adds **TwitchAdSolutions (vaft)**-style live ad blocking. - -Canonical living plan: session `active-plan.md` for conversation `37b527bf-e540-4753-be1f-39906fe2ba28`. ADR: **ADR-026**. - -## Problem - -Upstream Frosty plays Twitch HLS (native) or `player.twitch.tv` (WebView). Twitch may stitch ad pods into the media playlist (`stitched` / `EXT-X-DATERANGE`). Upstream only **detects** native ad breaks (to avoid bogus latency recovery); it does **not** block ads. Turbo/sub `hide_ads` via linked web session is separate and not a client blocker. - -## Approach (vaft-equivalent) - -Browser vaft hooks `Worker`/`fetch` and rewrites m3u8. Frosty’s native player cannot do that from Dart (`loadUrl` + headers only). - -| Layer | Behavior | -| --- | --- | -| Primary token | GQL `PlaybackAccessToken` with `playerType=popout` (vaft `ForceAccessTokenPlayerType`) when Settings `blockTwitchAds` is on | -| Ad break | Native `adBreakStream` → probe backup types `embed` → `popout` → `autoplay`; accept first Usher multivariant whose sampled media playlist lacks `stitched`; `loadUrl` that URL | -| Ad end | Restore primary popout Usher URL | -| WebView | Best-effort inject of vendored `assets/js/vaft.user.js` (not true document-start on Android) | -| UI | Settings toggle (default on; enabling forces native player); overlay “Blocking ads” | - -## Key code - -- `lib/apis/twitch_ad_solutions.dart` — constants + `TwitchAdFreePlayback` -- `lib/apis/twitch_gql_api.dart` — `playerType` / `platform`, `fetchPlaylistText` -- `lib/screens/channel/video/native_video_store.dart` — load + swap -- `lib/screens/channel/video/video_store.dart` — WebView inject -- `lib/screens/settings/stores/settings_store.dart` — `blockTwitchAds` -- `assets/js/vaft.user.js` — upstream TwitchAdSolutions script - -## Non-goals (v1) - -- Upstream PR -- Full Worker prototype cleaning -- Third-party proxies -- HLS segment stripping inside `better_native_video_player` (unless smoke tests demand it) - -## Tags + viewer sort (v5.2.2+) - -Helix cannot filter by freeform tags or sort ascending. GQL covers both: - -- Search: `SearchFreeformTags` → tap → `TagStreams` (`streams` + `freeformTags`) -- Sort: Top (asc), category (`game.streams`), tag — `VIEWER_COUNT` / `VIEWER_COUNT_ASC` via `StreamSortButton` - -Key code: `lib/apis/twitch_gql_api.dart`, `lib/screens/home/search/`, `lib/widgets/stream_sort_button.dart`, `ListStore` / `ListType.tag`. - -## Stream languages (v5.2.4+) - -Settings → General → Stream languages (multi-select chips). Default `de`. Applied to Top, category, and tag lists. - -- Helix `language=` when one language; multi-language / tags via Helix over-fetch + client filter -- Viewer sort is persisted (`streamViewerSort`) -- **Tag lists (asc + desc):** GQL `streams` with `freeformTags` + `sort` + cursor. Page-2+ uses Client-Integrity (same mint/retry as follow via `UserStore.runWithGqlIntegrity`). Helix + client tag filter only if integrity fails / no web session. -- **Follow / integrity:** Dio `POST /integrity` is almost always `is_bad_bot`. Follow retries via `IntegrityWebViewDialog` (capture `Client-Integrity` + `X-Device-Id` from Twitch's own WebView GQL traffic), then `UserStore.applyWebIntegrity`. -- **Top / category most viewers (desc):** Helix directory order (paginates) -- **Top / category fewest (asc):** GQL `VIEWER_COUNT_ASC` with the same integrity pagination; Helix fill + client re-sort as fallback - -## Build - -Flutter **3.47.0** (pinned in `pubspec.yaml`). Local SDK: `/home/yuna/.local/flutter-3.47.0`. diff --git a/README.md b/README.md index 834bbc44..6741da8d 100644 --- a/README.md +++ b/README.md @@ -85,17 +85,11 @@ For a more detailed overview, visit [frostyapp.io](https://www.frostyapp.io/). 3. Go to the [Twitch dev console](https://dev.twitch.tv/login) and register a new application to retrieve a **Client ID** and **Client Secret**. -4. Copy `.env.example` to `.env` and fill in your credentials: - - ```bash - cp .env.example .env - ``` +4. Use [`--dart-define`](https://dartcode.org/docs/using-dart-define-in-flutter/) to set the `clientId` and `secret` environment variables with your **Client ID** and **Client Secret**. 5. Run `flutter pub get` to fetch all the dependencies. -6. Choose an emulator or device and run the app! The included VS Code launch configurations will automatically read your `.env` file. - -7. (Optional) Run `dart run scripts/get_twitch_token.dart` to quickly get a user auth token. Long-press the **Anonymous** account tile in the app to log in. +6. Choose an emulator or device and run the app! > [!IMPORTANT] > Frosty uses [MobX](https://mobx.netlify.app/) for state management. Please refer to the documentation about code generation, otherwise your changes within MobX stores may not be applied. diff --git a/analysis_options.yaml b/analysis_options.yaml index 3b60af70..de45531d 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -16,9 +16,6 @@ analyzer: - ios/** - android/** - web/** - - windows/** - - macos/** - - linux/** linter: # The lint rules applied to this project can be customized in the diff --git a/android/Gemfile.lock b/android/Gemfile.lock index 41b69f5c..2cf8de81 100644 --- a/android/Gemfile.lock +++ b/android/Gemfile.lock @@ -3,13 +3,13 @@ GEM specs: CFPropertyList (3.0.8) abbrev (0.1.2) - addressable (2.9.0) + addressable (2.8.9) public_suffix (>= 2.0.2, < 8.0) artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1260.0) - aws-sdk-core (3.252.0) + aws-partitions (1.1220.0) + aws-sdk-core (3.242.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -17,19 +17,19 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.129.0) - aws-sdk-core (~> 3, >= 3.248.0) + aws-sdk-kms (1.122.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.225.1) - aws-sdk-core (~> 3, >= 3.248.0) + aws-sdk-s3 (1.213.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) - base64 (0.3.0) + base64 (0.2.0) benchmark (0.5.0) - bigdecimal (4.1.2) + bigdecimal (4.0.1) claide (1.1.0) colored (1.2) colored2 (3.1.2) @@ -68,18 +68,18 @@ GEM faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) - faraday-retry (1.0.4) + faraday-retry (1.0.3) faraday_middleware (1.2.1) faraday (~> 1.0) - fastimage (2.4.1) - fastlane (2.234.0) - CFPropertyList (>= 2.3, < 5.0.0) - abbrev (~> 0.1) + fastimage (2.4.0) + fastlane (2.232.2) + CFPropertyList (>= 2.3, < 4.0.0) + abbrev (~> 0.1.2) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) aws-sdk-s3 (~> 1.197) babosa (>= 1.0.3, < 2.0.0) - base64 (~> 0.2) + base64 (~> 0.2.0) benchmark (>= 0.1.0) bundler (>= 1.17.3, < 5.0.0) colored (~> 1.2) @@ -92,7 +92,7 @@ GEM faraday-cookie_jar (~> 0.0.6) faraday_middleware (~> 1.0) fastimage (>= 2.1.0, < 3.0.0) - fastlane-sirp (>= 1.1.0) + fastlane-sirp (>= 1.0.0) gh_inspector (>= 1.1.2, < 2.0.0) google-apis-androidpublisher_v3 (~> 0.3) google-apis-playcustomapp_v1 (~> 0.1) @@ -105,9 +105,9 @@ GEM logger (>= 1.6, < 2.0) mini_magick (>= 4.9.4, < 5.0.0) multipart-post (>= 2.0.0, < 3.0.0) - mutex_m (~> 0.3) + mutex_m (~> 0.3.0) naturally (~> 2.2) - nkf (~> 0.2) + nkf (~> 0.2.0) optparse (>= 0.1.1, < 1.0.0) ostruct (>= 0.1.0) plist (>= 3.1.0, < 4.0.0) @@ -122,9 +122,10 @@ GEM xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.4.1) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) - fastlane-sirp (1.1.0) + fastlane-sirp (1.0.0) + sysrandom (~> 1.0) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.102.0) + google-apis-androidpublisher_v3 (0.96.0) google-apis-core (>= 0.15.0, < 2.a) google-apis-core (0.18.0) addressable (~> 2.5, >= 2.5.1) @@ -134,19 +135,19 @@ GEM mutex_m representable (~> 3.0) retriable (>= 2.0, < 4.a) - google-apis-iamcredentials_v1 (0.27.0) + google-apis-iamcredentials_v1 (0.26.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-storage_v1 (0.61.0) google-apis-core (>= 0.15.0, < 2.a) - google-cloud-core (1.9.0) + google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) google-cloud-env (2.1.1) faraday (>= 1.0, < 3.a) - google-cloud-errors (1.6.0) - google-cloud-storage (1.61.0) + google-cloud-errors (1.5.0) + google-cloud-storage (1.58.0) addressable (~> 2.8) digest-crc (~> 0.4) google-apis-core (>= 0.18, < 2) @@ -168,41 +169,43 @@ GEM httpclient (2.9.0) mutex_m jmespath (1.6.2) - json (2.19.9) - jwt (2.10.3) + json (2.18.1) + jwt (2.10.2) base64 logger (1.7.0) mini_magick (4.13.2) mini_mime (1.1.5) - multi_json (1.21.1) + multi_json (1.19.1) multipart-post (2.4.1) mutex_m (0.3.0) nanaimo (0.4.0) naturally (2.3.0) - nkf (0.3.0) + nkf (0.2.0) optparse (0.8.1) os (1.1.4) ostruct (0.6.3) plist (3.7.2) - public_suffix (7.0.5) - rake (13.4.2) + public_suffix (7.0.2) + rake (13.3.1) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) - retriable (3.8.0) + retriable (3.2.1) rexml (3.4.4) rouge (3.28.0) ruby2_keywords (0.0.5) rubyzip (2.4.1) security (0.1.5) - signet (0.22.0) + signet (0.21.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) jwt (>= 1.5, < 4.0) + multi_json (~> 1.10) simctl (1.6.10) CFPropertyList naturally + sysrandom (1.0.5) terminal-notifier (2.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 998334f3..c9f675d5 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -6,6 +6,7 @@ plugins { id("com.google.gms.google-services") id("com.google.firebase.firebase-perf") id("com.google.firebase.crashlytics") + id("kotlin-android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } @@ -26,6 +27,10 @@ android { targetCompatibility = JavaVersion.VERSION_17 } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + defaultConfig { applicationId = "com.tommychow.frosty" minSdk = flutter.minSdkVersion @@ -45,26 +50,11 @@ android { buildTypes { release { - // Use the real upload key when key.properties is present (release - // workflows create it from secrets). Allow debug signing only when a CI - // compile-only check explicitly opts in via ALLOW_DEBUG_SIGNED_RELEASE; - // otherwise fail loudly so a real release can never be silently - // debug-signed if the keystore step is ever missing. - signingConfig = when { - keystorePropertiesFile.exists() -> signingConfigs.getByName("release") - System.getenv("ALLOW_DEBUG_SIGNED_RELEASE") != null -> signingConfigs.getByName("debug") - else -> error("key.properties missing — set ALLOW_DEBUG_SIGNED_RELEASE=1 for compile-only builds") - } + signingConfig = signingConfigs.getByName("release") } } } -kotlin { - compilerOptions { - jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 - } -} - flutter { source = "../.." } diff --git a/android/app/src/main/kotlin/com/tommychow/frosty/CookieExtractorPlugin.kt b/android/app/src/main/kotlin/com/tommychow/frosty/CookieExtractorPlugin.kt deleted file mode 100644 index 055101d0..00000000 --- a/android/app/src/main/kotlin/com/tommychow/frosty/CookieExtractorPlugin.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.tommychow.frosty - -import android.webkit.CookieManager -import io.flutter.embedding.engine.plugins.FlutterPlugin -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel - -class CookieExtractorPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { - private lateinit var channel: MethodChannel - - override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { - channel = MethodChannel(binding.binaryMessenger, "frosty/cookie_extractor") - channel.setMethodCallHandler(this) - } - - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - if (call.method == "extractTwitchAuthToken") { - val cookies = CookieManager.getInstance().getCookie("https://twitch.tv") - val authToken = cookies?.split(";") - ?.map { it.trim() } - ?.firstOrNull { it.startsWith("auth-token=") } - ?.substringAfter("auth-token=") - result.success(authToken) - } else { - result.notImplemented() - } - } - - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - channel.setMethodCallHandler(null) - } -} 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 30e59dc7..a7791025 100644 --- a/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt +++ b/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt @@ -1,11 +1,6 @@ package com.tommychow.frosty import cl.puntito.simple_pip_mode.PipCallbackHelperActivityWrapper -import io.flutter.embedding.engine.FlutterEngine class MainActivity: PipCallbackHelperActivityWrapper() { - override fun configureFlutterEngine(flutterEngine: FlutterEngine) { - super.configureFlutterEngine(flutterEngine) - flutterEngine.plugins.add(CookieExtractorPlugin()) - } } diff --git a/android/build.gradle.kts b/android/build.gradle.kts index b477a6e8..dbee657b 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,5 +1,3 @@ -import com.android.build.api.variant.LibraryAndroidComponentsExtension - allprojects { repositories { google() @@ -21,17 +19,6 @@ subprojects { project.evaluationDependsOn(":app") } -// AGP 9 requires each library to compile against at least the compileSdk of -// its dependencies. Several Flutter plugins still pin compileSdk 33/34. -// finalizeDsl runs after the plugin's android {} block. Does not change minSdk. -subprojects { - pluginManager.withPlugin("com.android.library") { - extensions.configure("androidComponents") { - finalizeDsl { it.compileSdk = 36 } - } - } -} - tasks.register("clean") { delete(rootProject.layout.buildDirectory) } diff --git a/android/fastlane/Fastfile b/android/fastlane/Fastfile index 19e69c65..0390c22d 100644 --- a/android/fastlane/Fastfile +++ b/android/fastlane/Fastfile @@ -17,14 +17,11 @@ default_platform(:android) platform :android do desc "Deploy a new version to the Google Play" - lane :deploy do |options| - # Upload to the Play Store using the provided credentials and AAB from flutter build. - # With dry_run:true (release dry-run workflow), validate_only authenticates and - # validates the AAB against Google Play without committing the release. + lane :deploy do + # Upload to the play store ussing the provided credentials and AAB from flutter build. upload_to_play_store( json_key_data: Base64.decode64(ENV["PLAY_STORE_CREDENTIALS"]), - aab: "../build/app/outputs/bundle/release/app-release.aab", - validate_only: options[:dry_run] || false + aab: "../build/app/outputs/bundle/release/app-release.aab" ) end end diff --git a/android/fastlane/metadata/android/en-US/short_description.txt b/android/fastlane/metadata/android/en-US/short_description.txt index cf35ecf1..e633a6b1 100644 --- a/android/fastlane/metadata/android/en-US/short_description.txt +++ b/android/fastlane/metadata/android/en-US/short_description.txt @@ -1 +1 @@ -Watch live streams and see community emotes in chat. +Watch and chat on Twitch with your favorite emotes from 7TV, BTTV, and FFZ! \ No newline at end of file diff --git a/android/fastlane/metadata/android/en-US/title.txt b/android/fastlane/metadata/android/en-US/title.txt index 445c1415..902e9c36 100644 --- a/android/fastlane/metadata/android/en-US/title.txt +++ b/android/fastlane/metadata/android/en-US/title.txt @@ -1 +1 @@ -Frosty +Frosty for Twitch \ No newline at end of file diff --git a/android/gradle.properties b/android/gradle.properties index 6fdfce35..01a11bb7 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,8 +1,4 @@ -org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError -android.useAndroidX=true -org.gradle.caching=true -org.gradle.parallel=true -# This builtInKotlin flag was added automatically by Flutter migrator -android.builtInKotlin=false -# This newDsl flag was added automatically by Flutter migrator -android.newDsl=false +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +org.gradle.caching=true +org.gradle.parallel=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index bcef617e..db3f4531 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index f68c56f0..f4978443 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -18,8 +18,8 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "9.1.0" apply false - id("org.jetbrains.kotlin.android") version "2.4.0" apply false + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false id("com.google.gms.google-services") version "4.4.4" apply false id("com.google.firebase.firebase-perf") version "2.0.2" apply false id("com.google.firebase.crashlytics") version "3.0.6" apply false diff --git a/assets/js/vaft.user.js b/assets/js/vaft.user.js deleted file mode 100644 index 47c3e971..00000000 --- a/assets/js/vaft.user.js +++ /dev/null @@ -1,1119 +0,0 @@ -// ==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 index 6d112234..ff43aef5 100644 --- a/assets/release-notes.md +++ b/assets/release-notes.md @@ -1,28 +1,113 @@ -## v5.3.1 +## v5.0.5 -October 12, 2026 +January 9, 2026 ### Bug Fixes -- Fixed the player getting stuck on a Source error after the screen turns off or the network switches — it now reloads automatically instead of waiting for a manual refresh. +- Fixed lag/frame drops in overlay chat mode. +- Fixed draggable divider still interactable in fullscreen mode. +- Fixed landscape bottom padding in chat bottom bar. +- [iOS] Fixed user agent breaking login. --- -## v5.3.0 +## v5.0.4 -October 12, 2026 +January 8, 2026 + +### Improvements + +- Swipe down to PiP gesture now works in landscape mode. +- The rotate button now adjusts to your device's physical orientation instead of always rotating to the same direction. +- Replaced "Fill notch side" setting with "Fill all edges" and made edge padding auto-adjust based on device rotation. + +### Bug Fixes + +- Fixed Google login not working. +- [iOS] Fixed bottom safe area in landscape mode. + +--- + +## v5.0.3 + +January 6, 2026 + +### Improvements + +- Reconnection messages now update in place instead of spamming chat. +- Chat now shows sending status and relevant error notifications when messages fail to send. +- [Android] The "Enhanced rendering" video setting has been renamed to "Fast video rendering" and reset to enabled for all users. + +### Bug Fixes + +- Fixed send button not working in landscape mode. +- Fixed text field input being cleared before server confirmation. + +--- + +## v5.0.2 + +January 4, 2026 + +### Improvements + +- Inline emote support in the chat text field. +- Search page now uses type-to-search for faster navigation. +- Restored ability to send messages while chat delay is active. +- Restored chat delay input indicator. +- Redesigned the reply bar. + +### Bug Fixes + +- Fixed reply bar and autocomplete bar blocking the bottom of chat. +- Fixed clear recent emotes button sizing. +- Fixed black screen when logging in. + +--- + +## v5.0.1 + +January 2, 2026 + +### Improvements + +- [Android] Enhanced video rendering is now enabled by default for better performance. Disable in settings if you experience crashes. +- [Android] Re-enabled Flutter Impeller rendering engine for better performance. +- Restored the "Show latency" setting in video overlay options. + +### Bug Fixes + +- [Android] Potentially fixed crash on app startup related to secure storage. + +--- + +## v5.0.0 + +January 1, 2026 ### New Features -- Added a Follow / Unfollow button under the player (between video and chat) for live and offline channels. +- Open multiple chat tabs for different channels with drag-to-reorder. Tabs are saved between sessions by default (configurable in settings). +- Resize the chat and video areas in landscape mode by dragging the divider. +- A new section in the Following tab shows which of your followed channels are currently offline. +- Change your chat username color from the chat details menu. +- A new randomize button for the accent color theme in settings. +- View emotes and stream thumbnails in full resolution with swipe-to-dismiss gestures. +- [Android] Open Twitch channel links directly in Frosty instead of the browser or Twitch app (by @micahmo on GitHub). ---- +### Improvements -## v5.2.9 - -October 7, 2026 +- Swipe down to enter Picture-in-Picture mode, and press the PiP button again to exit. +- Redesigned the video overlay and added stream details to the app bar in chat-only mode. +- Networking and data handling have been completely rewritten to be faster and more reliable. +- Improved formatting for chat replies, quick copy-to-input for messages, and a clearer countdown for chat delays. ### Bug Fixes -- Fixed tag stream lists reshuffling when loading more pages by paginating with Twitch’s sorted tag directory (GQL) instead of scanning Helix. -- Fixed follow/unfollow failing with an integrity check error while logged in by confirming via a short Twitch WebView when needed. +- Fixed chat text input not unfocusing when tapping outside of it (by @zeykafx on GitHub). +- Fixed missing badges and emotes in historical messages upon initial connection. +- Fixed accidental chat scrolling when accessing system UI gestures in landscape mode. +- Fixed chat reconnection issues when a chat delay was active. +- Fixed duplicate message cases and various UI stability issues. + +#### See all release notes at [https://github.com/tommyxchow/frosty/releases](https://github.com/tommyxchow/frosty/releases) diff --git a/docs/DESIGN-adblock.md b/docs/DESIGN-adblock.md deleted file mode 100644 index b226d01f..00000000 --- a/docs/DESIGN-adblock.md +++ /dev/null @@ -1,35 +0,0 @@ -# Design: Twitch ad blocking (vaft) in Frosty fork - -## Intent - -Personal Forgejo fork of Frosty blocks Twitch live ads using [TwitchAdSolutions vaft](https://github.com/pixeltris/TwitchAdSolutions) ideas, adapted to Flutter. - -## Upstream baseline - -Frosty **did not** ship an adblocker. The native player only **detects** stitched ad breaks (`adBreakStream`) to avoid bad latency/stall recovery. Turbo/sub may get `hide_ads` via linked web GQL session — server-side, not client blocking. - -## Strategy - -### Native player (primary — this is the reliable path) - -1. When `SettingsStore.blockTwitchAds` is true, request `PlaybackAccessToken` with `playerType=popout` (vaft `ForceAccessTokenPlayerType`) instead of `site`. -2. When the native plugin reports an ad break, resolve a backup Usher URL trying `embed` → `popout` → `autoplay`, preferring playlists whose media m3u8 does not contain `stitched`, then `loadUrl` that stream. -3. When the ad break ends, restore the primary popout URL. -4. Overlay shows “Blocking ads” while `_blockingAds` is true. - -Implementation: `lib/apis/twitch_ad_solutions.dart`, `lib/apis/twitch_gql_api.dart`, `lib/screens/channel/video/native_video_store.dart`. - -### WebView player (legacy, best-effort) - -Inject vendored `assets/js/vaft.user.js` on page start/finish. Not true `@run-at document-start` on Android via `webview_flutter`, so native remains preferred. Enabling adblock in Settings forces native on. - -## Settings - -- `blockTwitchAds` (default `true`) -- Turning adblock on sets `useNativePlayer = true` - -## Non-goals - -- Upstream PR -- Full browser Worker prototype rewriting on native -- Third-party CDN proxies unless player-type swap proves insufficient diff --git a/docs/OBTAINIUM.md b/docs/OBTAINIUM.md deleted file mode 100644 index 1a342ef0..00000000 --- a/docs/OBTAINIUM.md +++ /dev/null @@ -1,45 +0,0 @@ -# Obtainium (Android) - -Forgejo fork: [copilot/frosty](https://forgejo.fsociety00.cc/copilot/frosty) (**private**). - -Releases: `.forgejo/workflows/release.yml` on runner `desktop-release` (tag `v*` or workflow_dispatch). - -## Obtainium settings - -| Setting | Value | -|---|---| -| Source | Forgejo | -| URL | `https://forgejo.fsociety00.cc/api/v1/repos/copilot/frosty/releases` | -| APK filter | `frosty-arm64-v8a-v.*\.apk` | -| Trim version | `v?([0-9]+\.[0-9]+\.[0-9]+)` | -| Match group | `1` | - -Repo is private → add a Forgejo API token in Obtainium (same PAT as other Newas apps). - -Quick add: - -```text -obtainium://add/https://forgejo.fsociety00.cc/api/v1/repos/copilot/frosty/releases -``` - -## Signing / Play Store conflict - -Package id stays `com.tommychow.frosty`. Play Store Frosty and this fork use **different signing keys**, so Obtainium cannot update stock → fork in place. Uninstall the Play build first, then install from Obtainium. - -## CI secrets (Forgejo repo) - -| Secret | Purpose | -|---|---| -| `RELEASE_KEYSTORE_BASE64` | Same pattern as Flow / Finamp / Jellyfin forks | -| `STORE_PASSWORD` | Keystore password | -| `KEY_ALIAS` | Key alias | -| `KEY_PASSWORD` | Key password | -| `CLIENT_ID` | Twitch Helix app client id (`--dart-define`) | -| `SECRET` | Twitch Helix app secret (`--dart-define`) | - -## Cut a release - -1. Bump `version:` in `pubspec.yaml` (`x.y.z+build`) if needed. -2. Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` - or run **Actions → Release APK → Run workflow**. -3. Install / update via Obtainium once the release asset appears. diff --git a/docs/release.md b/docs/release.md deleted file mode 100644 index f9927590..00000000 --- a/docs/release.md +++ /dev/null @@ -1,57 +0,0 @@ -# Release — version, notes, tag, then stores - -Cut a GitHub + Play + App Store release from `main`. Not a changelog dump. Not a docs rewrite. - -This playbook lives in the repo and loads only when you open this workspace and ask to release. It is not a global skill. - -Flow: **preflight → bump + notes → commit on `main` → tag → wait for workflows → publish the GitHub draft.** - -The tag is the mechanical trigger. This file is the judgment pass around it. - -## Preflight - -Main CI green on current `HEAD`. Then `gh workflow run "Release dry-run"` on that `main` — the monthly heartbeat is not enough after a toolchain bump. - -Click any pending Apple agreement first (`developer.apple.com/account`, then App Store Connect Business) or the iOS job 403s. There is no API for that. On Play, glance Console Home and **Policy and programs > App content** (`Needs attention`); a DDA banner or new declaration can block the AAB the same way. - -## Version - -Release commit on `main` is only `pubspec.yaml` (`x.y.z+build`, bump both) and `assets/release-notes.md`. Message: `release vX.Y.Z`. - -Features since last tag → minor. Fixes only → patch. Build number +1. - -Do not put `AGENTS.md` or other docs in that commit. - -## Notes - -Write for someone opening the app, not the commit log. Match the latest shipped version's voice (`Added` / `Improved` / `Fixed`). Skip CI, Flutter upgrades, codegen, and anything a user wouldn't notice; the GitHub compare link is the rest. - -Keep about three most recent versions, consecutive. Drop from the oldest end. Never skip a version in the middle. GitHub releases keep the full history. - -- Features that didn't exist last release. Improvements only for behavior that already shipped — gating a new feature (opt-in permissions, an Enable flow) is not an improvement. -- If you can't remember the user-facing symptom, it doesn't belong. -- `[Android]` / `[iOS]` on platform-only lines. Cluster them: one tagged line at the end of its section, two or more adjacent. -- Credit GitHub PR authors as `(by @user on GitHub)` on each bullet they landed. Not AI, not issue reporters. The GitHub release body strips ` on GitHub` as redundant. - -Example shape (from v5.1.0): `Added a new experimental native video player option with quality selection…` not `Bumped better_native_video_player`. - -## Tag - -```bash -git commit -m "release vX.Y.Z" -git push origin main -git tag vX.Y.Z # lightweight, on that commit -git push origin vX.Y.Z # v*.*.* triggers both workflows -``` - -Do not `gh release create`. **Draft GitHub release** attaches `frosty-vX.Y.Z.apk` and fills the body from this version's section in `assets/release-notes.md`. **Build and release Frosty** uploads the Play AAB and submits iOS for review (`automatic_release: true`). - -When the draft has the APK: - -```bash -gh release edit vX.Y.Z --draft=false -``` - -The README latest-release badge stays stale until that publish. Store listing copy already points at GitHub — don't add per-version Play changelog files. - -Do not push the tag until the release commit is on `origin/main`. diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 391a902b..0d140800 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -20,5 +20,7 @@ ???? CFBundleVersion 1.0 + MinimumOSVersion + 15.0 diff --git a/ios/Gemfile.lock b/ios/Gemfile.lock index 7d14f995..06923c0a 100644 --- a/ios/Gemfile.lock +++ b/ios/Gemfile.lock @@ -3,13 +3,13 @@ GEM specs: CFPropertyList (3.0.8) abbrev (0.1.2) - addressable (2.9.0) + addressable (2.8.8) public_suffix (>= 2.0.2, < 8.0) artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1260.0) - aws-sdk-core (3.252.0) + aws-partitions (1.1200.0) + aws-sdk-core (3.240.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -17,19 +17,18 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.129.0) - aws-sdk-core (~> 3, >= 3.248.0) + aws-sdk-kms (1.118.0) + aws-sdk-core (~> 3, >= 3.239.1) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.225.1) - aws-sdk-core (~> 3, >= 3.248.0) + aws-sdk-s3 (1.209.0) + aws-sdk-core (~> 3, >= 3.234.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) - base64 (0.3.0) - benchmark (0.5.0) - bigdecimal (4.1.2) + base64 (0.2.0) + bigdecimal (4.0.1) claide (1.1.0) colored (1.2) colored2 (3.1.2) @@ -43,7 +42,7 @@ GEM dotenv (2.8.1) emoji_regex (3.2.3) excon (0.112.0) - faraday (1.10.5) + faraday (1.10.4) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) @@ -68,20 +67,19 @@ GEM faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) - faraday-retry (1.0.4) + faraday-retry (1.0.3) faraday_middleware (1.2.1) faraday (~> 1.0) - fastimage (2.4.1) - fastlane (2.234.0) - CFPropertyList (>= 2.3, < 5.0.0) - abbrev (~> 0.1) + fastimage (2.4.0) + fastlane (2.230.0) + CFPropertyList (>= 2.3, < 4.0.0) + abbrev (~> 0.1.2) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) - aws-sdk-s3 (~> 1.197) + aws-sdk-s3 (~> 1.0) babosa (>= 1.0.3, < 2.0.0) - base64 (~> 0.2) - benchmark (>= 0.1.0) - bundler (>= 1.17.3, < 5.0.0) + base64 (~> 0.2.0) + bundler (>= 1.12.0, < 3.0.0) colored (~> 1.2) commander (~> 4.6) csv (~> 3.3) @@ -92,11 +90,11 @@ GEM faraday-cookie_jar (~> 0.0.6) faraday_middleware (~> 1.0) fastimage (>= 2.1.0, < 3.0.0) - fastlane-sirp (>= 1.1.0) + fastlane-sirp (>= 1.0.0) gh_inspector (>= 1.1.2, < 2.0.0) google-apis-androidpublisher_v3 (~> 0.3) google-apis-playcustomapp_v1 (~> 0.1) - google-cloud-env (>= 1.6.0, <= 2.1.1) + google-cloud-env (>= 1.6.0, < 2.0.0) google-cloud-storage (~> 1.31) highline (~> 2.0) http-cookie (~> 1.0.5) @@ -105,11 +103,10 @@ GEM logger (>= 1.6, < 2.0) mini_magick (>= 4.9.4, < 5.0.0) multipart-post (>= 2.0.0, < 3.0.0) - mutex_m (~> 0.3) + mutex_m (~> 0.3.0) naturally (~> 2.2) - nkf (~> 0.2) + nkf (~> 0.2.0) optparse (>= 0.1.1, < 1.0.0) - ostruct (>= 0.1.0) plist (>= 3.1.0, < 4.0.0) rubyzip (>= 2.0.0, < 3.0.0) security (= 0.1.5) @@ -122,42 +119,41 @@ GEM xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.4.1) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) - fastlane-sirp (1.1.0) + fastlane-sirp (1.0.0) + sysrandom (~> 1.0) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.102.0) - google-apis-core (>= 0.15.0, < 2.a) - google-apis-core (0.18.0) + google-apis-androidpublisher_v3 (0.54.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-core (0.11.3) addressable (~> 2.5, >= 2.5.1) - googleauth (~> 1.9) - httpclient (>= 2.8.3, < 3.a) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) mini_mime (~> 1.0) - mutex_m representable (~> 3.0) retriable (>= 2.0, < 4.a) - google-apis-iamcredentials_v1 (0.27.0) - google-apis-core (>= 0.15.0, < 2.a) - google-apis-playcustomapp_v1 (0.17.0) - google-apis-core (>= 0.15.0, < 2.a) - google-apis-storage_v1 (0.63.0) - google-apis-core (>= 0.15.0, < 2.a) - google-cloud-core (1.9.0) + rexml + google-apis-iamcredentials_v1 (0.17.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-playcustomapp_v1 (0.13.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-storage_v1 (0.31.0) + google-apis-core (>= 0.11.0, < 2.a) + google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) - google-cloud-env (2.1.1) - faraday (>= 1.0, < 3.a) - google-cloud-errors (1.6.0) - google-cloud-storage (1.61.0) + google-cloud-env (1.6.0) + faraday (>= 0.17.3, < 3.0) + google-cloud-errors (1.5.0) + google-cloud-storage (1.47.0) addressable (~> 2.8) digest-crc (~> 0.4) - google-apis-core (>= 0.18, < 2) - google-apis-iamcredentials_v1 (~> 0.18) - google-apis-storage_v1 (>= 0.42) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.31.0) google-cloud-core (~> 1.6) - googleauth (~> 1.9) + googleauth (>= 0.16.2, < 2.a) mini_mime (~> 1.0) - googleauth (1.11.2) - faraday (>= 1.0, < 3.a) - google-cloud-env (~> 2.1) + googleauth (1.8.1) + faraday (>= 0.17.3, < 3.a) jwt (>= 1.4, < 3.0) multi_json (~> 1.11) os (>= 0.9, < 2.0) @@ -168,41 +164,42 @@ GEM httpclient (2.9.0) mutex_m jmespath (1.6.2) - json (2.19.9) - jwt (2.10.3) + json (2.18.0) + jwt (2.10.2) base64 logger (1.7.0) mini_magick (4.13.2) mini_mime (1.1.5) - multi_json (1.21.1) + multi_json (1.19.1) multipart-post (2.4.1) mutex_m (0.3.0) nanaimo (0.4.0) naturally (2.3.0) - nkf (0.3.0) + nkf (0.2.0) optparse (0.8.1) os (1.1.4) - ostruct (0.6.3) plist (3.7.2) - public_suffix (7.0.5) - rake (13.4.2) + public_suffix (7.0.0) + rake (13.3.1) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) - retriable (3.8.0) + retriable (3.1.2) rexml (3.4.4) rouge (3.28.0) ruby2_keywords (0.0.5) rubyzip (2.4.1) security (0.1.5) - signet (0.22.0) + signet (0.21.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) jwt (>= 1.5, < 4.0) + multi_json (~> 1.10) simctl (1.6.10) CFPropertyList naturally + sysrandom (1.0.5) terminal-notifier (2.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 560b1f09..88874273 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,27 +1,321 @@ PODS: - advanced_in_app_review (0.0.1): - Flutter + - app_links (7.0.0): + - Flutter + - device_info_plus (0.0.1): + - Flutter + - Firebase/CoreOnly (12.6.0): + - FirebaseCore (~> 12.6.0) + - Firebase/Crashlytics (12.6.0): + - Firebase/CoreOnly + - FirebaseCrashlytics (~> 12.6.0) + - Firebase/Performance (12.6.0): + - Firebase/CoreOnly + - FirebasePerformance (~> 12.6.0) + - firebase_analytics (12.1.0): + - firebase_core + - FirebaseAnalytics (= 12.6.0) + - Flutter + - firebase_core (4.3.0): + - Firebase/CoreOnly (= 12.6.0) + - Flutter + - firebase_crashlytics (5.0.6): + - Firebase/Crashlytics (= 12.6.0) + - firebase_core + - Flutter + - firebase_performance (0.11.1-3): + - Firebase/Performance (= 12.6.0) + - firebase_core + - Flutter + - FirebaseABTesting (12.6.0): + - FirebaseCore (~> 12.6.0) + - FirebaseAnalytics (12.6.0): + - FirebaseAnalytics/Default (= 12.6.0) + - FirebaseCore (~> 12.6.0) + - FirebaseInstallations (~> 12.6.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - FirebaseAnalytics/Default (12.6.0): + - FirebaseCore (~> 12.6.0) + - FirebaseInstallations (~> 12.6.0) + - GoogleAppMeasurement/Default (= 12.6.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - FirebaseCore (12.6.0): + - FirebaseCoreInternal (~> 12.6.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - FirebaseCoreExtension (12.6.0): + - FirebaseCore (~> 12.6.0) + - FirebaseCoreInternal (12.6.0): + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseCrashlytics (12.6.0): + - FirebaseCore (~> 12.6.0) + - FirebaseInstallations (~> 12.6.0) + - FirebaseRemoteConfigInterop (~> 12.6.0) + - FirebaseSessions (~> 12.6.0) + - GoogleDataTransport (~> 10.1) + - GoogleUtilities/Environment (~> 8.1) + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) + - FirebaseInstallations (12.6.0): + - FirebaseCore (~> 12.6.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - PromisesObjC (~> 2.4) + - FirebasePerformance (12.6.0): + - FirebaseCore (~> 12.6.0) + - FirebaseInstallations (~> 12.6.0) + - FirebaseRemoteConfig (~> 12.6.0) + - FirebaseSessions (~> 12.6.0) + - GoogleDataTransport (~> 10.1) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - nanopb (~> 3.30910.0) + - FirebaseRemoteConfig (12.6.0): + - FirebaseABTesting (~> 12.6.0) + - FirebaseCore (~> 12.6.0) + - FirebaseInstallations (~> 12.6.0) + - FirebaseRemoteConfigInterop (~> 12.6.0) + - FirebaseSharedSwift (~> 12.6.0) + - GoogleUtilities/Environment (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseRemoteConfigInterop (12.6.0) + - FirebaseSessions (12.6.0): + - FirebaseCore (~> 12.6.0) + - FirebaseCoreExtension (~> 12.6.0) + - FirebaseInstallations (~> 12.6.0) + - GoogleDataTransport (~> 10.1) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - nanopb (~> 3.30910.0) + - PromisesSwift (~> 2.1) + - FirebaseSharedSwift (12.6.0) - Flutter (1.0.0) + - flutter_native_splash (2.4.3): + - Flutter - flutter_secure_storage (6.0.0): - Flutter + - GoogleAdsOnDeviceConversion (3.2.0): + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - nanopb (~> 3.30910.0) + - GoogleAppMeasurement/Core (12.6.0): + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - GoogleAppMeasurement/Default (12.6.0): + - GoogleAdsOnDeviceConversion (~> 3.2.0) + - GoogleAppMeasurement/Core (= 12.6.0) + - GoogleAppMeasurement/IdentitySupport (= 12.6.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - GoogleAppMeasurement/IdentitySupport (12.6.0): + - GoogleAppMeasurement/Core (= 12.6.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - GoogleDataTransport (10.1.0): + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) + - GoogleUtilities/AppDelegateSwizzler (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Privacy + - GoogleUtilities/Environment (8.1.0): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/MethodSwizzler (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/Network (8.1.0): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Privacy + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (8.1.0)": + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/Reachability (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/UserDefaults (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - in_app_review (2.0.0): + - Flutter + - nanopb (3.30910.0): + - nanopb/decode (= 3.30910.0) + - nanopb/encode (= 3.30910.0) + - nanopb/decode (3.30910.0) + - nanopb/encode (3.30910.0) + - native_device_orientation (0.0.1): + - Flutter + - package_info_plus (0.4.5): + - Flutter + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - PromisesObjC (2.4.0) + - PromisesSwift (2.4.0): + - PromisesObjC (= 2.4.0) + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - url_launcher_ios (0.0.1): + - Flutter + - wakelock_plus (0.0.1): + - Flutter + - webview_flutter_wkwebview (0.0.1): + - Flutter + - FlutterMacOS DEPENDENCIES: - advanced_in_app_review (from `.symlinks/plugins/advanced_in_app_review/ios`) + - app_links (from `.symlinks/plugins/app_links/ios`) + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) + - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) + - firebase_core (from `.symlinks/plugins/firebase_core/ios`) + - firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`) + - firebase_performance (from `.symlinks/plugins/firebase_performance/ios`) - Flutter (from `Flutter`) + - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) + - in_app_review (from `.symlinks/plugins/in_app_review/ios`) + - native_device_orientation (from `.symlinks/plugins/native_device_orientation/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) + - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`) + +SPEC REPOS: + trunk: + - Firebase + - FirebaseABTesting + - FirebaseAnalytics + - FirebaseCore + - FirebaseCoreExtension + - FirebaseCoreInternal + - FirebaseCrashlytics + - FirebaseInstallations + - FirebasePerformance + - FirebaseRemoteConfig + - FirebaseRemoteConfigInterop + - FirebaseSessions + - FirebaseSharedSwift + - GoogleAdsOnDeviceConversion + - GoogleAppMeasurement + - GoogleDataTransport + - GoogleUtilities + - nanopb + - PromisesObjC + - PromisesSwift EXTERNAL SOURCES: advanced_in_app_review: :path: ".symlinks/plugins/advanced_in_app_review/ios" + app_links: + :path: ".symlinks/plugins/app_links/ios" + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" + firebase_analytics: + :path: ".symlinks/plugins/firebase_analytics/ios" + firebase_core: + :path: ".symlinks/plugins/firebase_core/ios" + firebase_crashlytics: + :path: ".symlinks/plugins/firebase_crashlytics/ios" + firebase_performance: + :path: ".symlinks/plugins/firebase_performance/ios" Flutter: :path: Flutter + flutter_native_splash: + :path: ".symlinks/plugins/flutter_native_splash/ios" flutter_secure_storage: :path: ".symlinks/plugins/flutter_secure_storage/ios" + in_app_review: + :path: ".symlinks/plugins/in_app_review/ios" + native_device_orientation: + :path: ".symlinks/plugins/native_device_orientation/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + wakelock_plus: + :path: ".symlinks/plugins/wakelock_plus/ios" + webview_flutter_wkwebview: + :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" SPEC CHECKSUMS: - advanced_in_app_review: daebe4c801525b756fe589de60fd327cb12f965c - Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47 - flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 + advanced_in_app_review: fe8fd50b8670cb3dbe67e4c26ca643f7aea8dbb8 + app_links: 6d01271b3907b0ee7325c5297c75d697c4226c4d + device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 + Firebase: a451a7b61536298fd5cbfe3a746fd40443a50679 + firebase_analytics: 1c038d9e4edf25828a8985c0be4efcb84cdae32a + firebase_core: 7ca5e04fc97329a2245376eba53c5bed113ca21e + firebase_crashlytics: eb4e3214716bff15ccd3a554dd1cf7922115c430 + firebase_performance: 2ff356e47b63483a0da655b73c70eb780838c4e3 + FirebaseABTesting: 119f0a2b2e68b1ae05d248c5adb2455f148f20c1 + FirebaseAnalytics: d0a97a0db6425e5a5d966340b87f92ca7b13a557 + FirebaseCore: 0e38ad5d62d980a47a64b8e9301ffa311457be04 + FirebaseCoreExtension: 032fd6f8509e591fda8cb76f6651f20d926b121f + FirebaseCoreInternal: 69bf1306a05b8ac43004f6cc1f804bb7b05b229e + FirebaseCrashlytics: 3d6248c50726ee7832aef0e53cb84c9e64d9fa7e + FirebaseInstallations: 631b38da2e11a83daa4bfb482f79d286a5dfa7ad + FirebasePerformance: 9a73b170cefb06e5da537682e30f86c9865880c5 + FirebaseRemoteConfig: c5dfe22828a7ae7673d16224ea92743687e993df + FirebaseRemoteConfigInterop: 3443b8cb8fffd76bb3e03b2a84bfd3db952fcda4 + FirebaseSessions: 2e8f808347e665dff3e5843f275715f07045297d + FirebaseSharedSwift: 79f27fff0addd15c3de19b87fba426f3cc2c964f + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29 + flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 + GoogleAdsOnDeviceConversion: d68c69dd9581a0f5da02617b6f377e5be483970f + GoogleAppMeasurement: 3bf40aff49a601af5da1c3345702fcb4991d35ee + GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 + in_app_review: 436034b18594851a7328d7f1c2ed5ec235b79cfc + nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 + native_device_orientation: 42487b19c552fb5a6a2b51e61b93366f628113df + package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 + path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 + PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 + shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa + wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e + webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4 PODFILE CHECKSUM: 57c8aed26fba39d3ec9424816221f294a07c58eb diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index c83d966a..3c0a4184 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -11,13 +11,11 @@ 3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E751BC99755AAC391DC0B29F /* Pods_Runner.framework */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - F1C00CE01A2B3C4D00E5F6A8 /* CookieExtractorPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; BE8095795EAFF58C08A43B48 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A68609F67C30CFEF267CD4D3 /* GoogleService-Info.plist */; }; D4F5A60712340001ABCD1234 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */; }; - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -40,7 +38,6 @@ 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 = ""; }; @@ -54,7 +51,6 @@ CFF3F6A0DA5A835B219C2D3C /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 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 */ @@ -62,7 +58,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, 3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -73,7 +68,6 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -112,7 +106,6 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, - F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */, ); @@ -141,9 +134,6 @@ /* Begin PBXNativeTarget section */ 97C146ED1CF9000F007C117D /* Runner */ = { - packageProductDependencies = ( - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, - ); isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( @@ -170,9 +160,6 @@ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { - packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, - ); isa = PBXProject; attributes = { LastUpgradeCheck = 1510; @@ -259,7 +246,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "# Symbol upload only matters for builds that ship; Debug dSYMs are absent or empty and fail the tool.\nif [ \"$CONFIGURATION\" = \"Debug\" ]; then\n echo \"Skipping Crashlytics symbol upload for Debug build\"\n exit 0\nfi\n# Locate upload-symbols whether Firebase is integrated via CocoaPods or Swift Package Manager.\nBIN=\"$PODS_ROOT/FirebaseCrashlytics/upload-symbols\"\n[ -f \"$BIN\" ] || BIN=\"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/upload-symbols\"\n\"$BIN\" --flutter-project \"$PROJECT_DIR/firebase_app_id_file.json\"\n"; + shellScript = "\"$PODS_ROOT/FirebaseCrashlytics/upload-symbols\" --flutter-project \"$PROJECT_DIR/firebase_app_id_file.json\" "; }; 34CC65D58A1948B306772884 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; @@ -317,7 +304,6 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, - F1C00CE01A2B3C4D00E5F6A8 /* CookieExtractorPlugin.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -604,18 +590,6 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ -/* Begin XCLocalSwiftPackageReference section */ - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; - }; -/* End XCLocalSwiftPackageReference section */ -/* Begin XCSwiftPackageProductDependency section */ - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { - isa = XCSwiftPackageProductDependency; - productName = FlutterGeneratedPluginSwiftPackage; - }; -/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 6839f6d5..00000000 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,122 +0,0 @@ -{ - "pins" : [ - { - "identity" : "abseil-cpp-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/abseil-cpp-binary.git", - "state" : { - "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", - "version" : "1.2024072200.0" - } - }, - { - "identity" : "app-check", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/app-check.git", - "state" : { - "revision" : "bb4002485ff867768dec13bf904a2ddb050bd1b1", - "version" : "11.3.0" - } - }, - { - "identity" : "firebase-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/firebase-ios-sdk", - "state" : { - "revision" : "33a468adfdb75b53f05a37e7c886ca7c962b5c17", - "version" : "12.17.0" - } - }, - { - "identity" : "google-ads-on-device-conversion-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", - "state" : { - "revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64", - "version" : "3.6.0" - } - }, - { - "identity" : "googleappmeasurement", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleAppMeasurement.git", - "state" : { - "revision" : "fceaffa07d22dcd5624d3639fd970351a4a5ad8c", - "version" : "12.17.0" - } - }, - { - "identity" : "googledatatransport", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleDataTransport.git", - "state" : { - "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", - "version" : "10.1.0" - } - }, - { - "identity" : "googleutilities", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleUtilities.git", - "state" : { - "revision" : "c46e5f8b7c23265f17c24ca7f9fa1b13ded7a822", - "version" : "8.1.1" - } - }, - { - "identity" : "grpc-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/grpc-binary.git", - "state" : { - "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", - "version" : "1.69.1" - } - }, - { - "identity" : "gtm-session-fetcher", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/gtm-session-fetcher.git", - "state" : { - "revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a", - "version" : "5.3.0" - } - }, - { - "identity" : "interop-ios-for-google-sdks", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/interop-ios-for-google-sdks.git", - "state" : { - "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", - "version" : "101.0.0" - } - }, - { - "identity" : "leveldb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/leveldb.git", - "state" : { - "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", - "version" : "1.22.5" - } - }, - { - "identity" : "nanopb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/nanopb.git", - "state" : { - "revision" : "3851d94a41890dea16dc3db34caf60e585cb4163", - "version" : "2.30910.1" - } - }, - { - "identity" : "promises", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/promises.git", - "state" : { - "revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837", - "version" : "2.4.1" - } - } - ], - "version" : 2 -} diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 5db441f5..9c12df59 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,24 +5,6 @@ - - - - - - - - - - Bool { + GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } - - func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { - GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) - guard let cookieExtractorRegistrar = engineBridge.pluginRegistry.registrar( - forPlugin: "CookieExtractorPlugin" - ) else { - assertionFailure("Failed to create CookieExtractorPlugin registrar") - return - } - CookieExtractorPlugin.register(with: cookieExtractorRegistrar) - } } - diff --git a/ios/Runner/CookieExtractorPlugin.swift b/ios/Runner/CookieExtractorPlugin.swift deleted file mode 100644 index 24495865..00000000 --- a/ios/Runner/CookieExtractorPlugin.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Flutter -import WebKit - -class CookieExtractorPlugin: NSObject, FlutterPlugin { - static func register(with registrar: FlutterPluginRegistrar) { - let channel = FlutterMethodChannel( - name: "frosty/cookie_extractor", - binaryMessenger: registrar.messenger() - ) - let instance = CookieExtractorPlugin() - registrar.addMethodCallDelegate(instance, channel: channel) - } - - func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - if call.method == "extractTwitchAuthToken" { - WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in - let authCookie = cookies.first { - $0.name == "auth-token" && - ($0.domain == "twitch.tv" || $0.domain.hasSuffix(".twitch.tv")) - } - result(authCookie?.value) - } - } else { - result(FlutterMethodNotImplemented) - } - } -} diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 1f85dcd5..8aa7eded 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -52,32 +52,7 @@ UIApplicationSupportsIndirectInputEvents - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneClassName - UIWindowScene - UISceneDelegateClassName - FlutterSceneDelegate - UISceneConfigurationName - flutter - UISceneStoryboardFile - Main - - - - UIStatusBarHidden - UIBackgroundModes - - audio - diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index df241cb0..78bcb074 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -17,9 +17,7 @@ default_platform(:ios) platform :ios do desc 'Push a new release build to the App Store' - lane :release do |options| - dry_run = options[:dry_run] || false - + lane :release do # Setup for CI. Uses a keychain to prevent the CI from hanging due to password prompts. setup_ci @@ -31,9 +29,8 @@ platform :ios do is_key_content_base64: true ) - # Manage code signing (with match). readonly on a dry run so we only verify the - # existing cert/profile resolve and never regenerate or mutate the certificates repo. - sync_code_signing(readonly: dry_run) + # Manage code signing (with match). + sync_code_signing # Automatic signing is used during development, but fastlane needs to use manual signing. update_code_signing_settings( @@ -48,11 +45,6 @@ platform :ios do output_directory: '../build/ios/archive' ) - # On a dry run, stop here: the signed archive above exercises every step that - # actually fails (ASC key, match certs/profiles, signing, release build). The - # App Store upload itself is the only remaining unverified step. - next if dry_run - # Upload the app to the App Store. upload_to_app_store( submit_for_review: true, diff --git a/ios/fastlane/metadata/en-US/name.txt b/ios/fastlane/metadata/en-US/name.txt index 445c1415..533e8f10 100644 --- a/ios/fastlane/metadata/en-US/name.txt +++ b/ios/fastlane/metadata/en-US/name.txt @@ -1 +1 @@ -Frosty +Frosty for Twitch diff --git a/ios/fastlane/metadata/en-US/promotional_text.txt b/ios/fastlane/metadata/en-US/promotional_text.txt index cf35ecf1..e633a6b1 100644 --- a/ios/fastlane/metadata/en-US/promotional_text.txt +++ b/ios/fastlane/metadata/en-US/promotional_text.txt @@ -1 +1 @@ -Watch live streams and see community emotes in chat. +Watch and chat on Twitch with your favorite emotes from 7TV, BTTV, and FFZ! \ 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 8c1195c5..0712b6dc 100644 --- a/ios/fastlane/metadata/en-US/subtitle.txt +++ b/ios/fastlane/metadata/en-US/subtitle.txt @@ -1 +1 @@ -Community emotes in chat +See 7TV, BTTV, and FFZ emotes! diff --git a/lib/apis/base_api_client.dart b/lib/apis/base_api_client.dart index 87163d84..5c8a18e0 100644 --- a/lib/apis/base_api_client.dart +++ b/lib/apis/base_api_client.dart @@ -1,5 +1,4 @@ import 'package:dio/dio.dart'; -import 'package:firebase_crashlytics/firebase_crashlytics.dart'; /// Common type aliases to reduce repetition typedef JsonMap = Map; @@ -44,13 +43,6 @@ class UnauthorizedException extends ApiException { : super(message, 401, DioExceptionType.badResponse); } -/// Twitch GQL rejected the request for a missing/invalid Client-Integrity token. -class IntegrityCheckException extends ApiException { - const IntegrityCheckException([ - String message = 'Twitch integrity check failed', - ]) : super(message); -} - /// Base API client to eliminate code duplication across API services abstract class BaseApiClient { final Dio _dio; @@ -165,40 +157,29 @@ abstract class BaseApiClient { /// Converts DioException to appropriate ApiException subtype ApiException _handleError(DioException error) { - final exception = switch (error.type) { - DioExceptionType.connectionTimeout || - DioExceptionType.sendTimeout || - DioExceptionType.receiveTimeout || - DioExceptionType.transformTimeout => - TimeoutException(_getTimeoutMessage(error.type)), - DioExceptionType.connectionError => NetworkException( - 'No internet connection. Please check your network.', - ), - DioExceptionType.badResponse => _handleHttpError(error), - DioExceptionType.cancel => ApiException('Request was cancelled'), - DioExceptionType.badCertificate => - ApiException('Security certificate error'), - DioExceptionType.unknown => - ApiException(error.message ?? 'An unexpected error occurred'), - }; + switch (error.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + return TimeoutException(_getTimeoutMessage(error.type)); - // Record server errors and unexpected failures to Crashlytics. - // Skip timeouts, network errors (normal offline conditions), 401s, and cancellations. - if (exception is ServerException || - error.type == DioExceptionType.unknown || - error.type == DioExceptionType.badCertificate) { - try { - FirebaseCrashlytics.instance.recordError( - exception, - error.stackTrace, - reason: '${error.requestOptions.method} ${error.requestOptions.uri}', + case DioExceptionType.connectionError: + return NetworkException( + 'No internet connection. Please check your network.', ); - } catch (_) { - // Firebase may not be initialized (e.g., in tests). - } - } - return exception; + case DioExceptionType.badResponse: + return _handleHttpError(error); + + case DioExceptionType.cancel: + return ApiException('Request was cancelled'); + + case DioExceptionType.badCertificate: + return ApiException('Security certificate error'); + + case DioExceptionType.unknown: + return ApiException(error.message ?? 'An unexpected error occurred'); + } } /// Gets specific timeout message based on timeout type @@ -210,8 +191,6 @@ abstract class BaseApiClient { return 'Upload timeout. Please try again.'; case DioExceptionType.receiveTimeout: return 'Server response timeout. Please try again.'; - case DioExceptionType.transformTimeout: - return 'Response processing timeout. Please try again.'; default: return 'Request timeout. Please try again.'; } diff --git a/lib/apis/dio_client.dart b/lib/apis/dio_client.dart index 1ba5e538..13cd2197 100644 --- a/lib/apis/dio_client.dart +++ b/lib/apis/dio_client.dart @@ -1,6 +1,5 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; -import 'package:frosty/apis/firebase_performance_interceptor.dart'; /// Centralized Dio client configuration with interceptors and error handling class DioClient { @@ -56,9 +55,6 @@ class DioClient { ); } - // Trace HTTP requests as Firebase Performance metrics - dio.interceptors.add(FirebasePerformanceInterceptor()); - // Add any additional interceptors provided if (additionalInterceptors != null) { for (final interceptor in additionalInterceptors) { diff --git a/lib/apis/ffz_api.dart b/lib/apis/ffz_api.dart index 32e70d19..d1918dee 100644 --- a/lib/apis/ffz_api.dart +++ b/lib/apis/ffz_api.dart @@ -42,7 +42,7 @@ class FFZApi extends BaseApiClient { ); } - /// Returns a map of user IDs to a list of their FFZ badges. + /// Returns a map of badges user IDs to a list of their FFZ badges. Future>> getBadges() async { final data = await get('/badges/ids'); diff --git a/lib/apis/firebase_performance_interceptor.dart b/lib/apis/firebase_performance_interceptor.dart deleted file mode 100644 index f1c6712b..00000000 --- a/lib/apis/firebase_performance_interceptor.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:firebase_performance/firebase_performance.dart'; - -/// Dio interceptor that traces HTTP requests as Firebase Performance metrics. -class FirebasePerformanceInterceptor extends Interceptor { - static const _metricKey = 'firebase_performance_metric'; - - @override - void onRequest(RequestOptions options, RequestInterceptorHandler handler) { - final metric = FirebasePerformance.instance.newHttpMetric( - options.uri.toString(), - _getHttpMethod(options.method), - ); - metric.start(); - options.extra[_metricKey] = metric; - handler.next(options); - } - - @override - void onResponse(Response response, ResponseInterceptorHandler handler) { - _stopMetric(response.requestOptions, response.statusCode); - handler.next(response); - } - - @override - void onError(DioException err, ErrorInterceptorHandler handler) { - _stopMetric(err.requestOptions, err.response?.statusCode); - handler.next(err); - } - - void _stopMetric(RequestOptions options, int? statusCode) { - final metric = options.extra.remove(_metricKey) as HttpMetric?; - if (metric == null) return; - if (statusCode != null) metric.httpResponseCode = statusCode; - metric.stop(); - } - - static HttpMethod _getHttpMethod(String method) => switch (method.toUpperCase()) { - 'GET' => HttpMethod.Get, - 'POST' => HttpMethod.Post, - 'PUT' => HttpMethod.Put, - 'DELETE' => HttpMethod.Delete, - 'PATCH' => HttpMethod.Patch, - _ => HttpMethod.Get, - }; -} diff --git a/lib/apis/twitch_ad_solutions.dart b/lib/apis/twitch_ad_solutions.dart deleted file mode 100644 index 1647f4f0..00000000 --- a/lib/apis/twitch_ad_solutions.dart +++ /dev/null @@ -1,163 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:frosty/apis/twitch_gql_api.dart'; -import 'package:frosty/models/playback_access_token.dart'; - -/// Dart-side port of the [TwitchAdSolutions vaft](https://github.com/pixeltris/TwitchAdSolutions) -/// strategy for Frosty's native HLS player. -/// -/// Browser vaft hooks `Worker`/`fetch` to rewrite playlists. Here we instead: -/// 1. Request playback tokens with a non-`site` [forceAccessTokenPlayerType] -/// (vaft's `ForceAccessTokenPlayerType`). -/// 2. When stitched ads still appear, try [backupPlayerTypes] until a playlist -/// without the ad signifier is found, then hand that Usher URL to the player. -class TwitchAdSolutions { - TwitchAdSolutions._(); - - /// Matches vaft `AdSignifier` — Twitch marks ad pods with this in `#EXT-X-DATERANGE`. - static const adSignifier = 'stitched'; - - /// vaft `ForceAccessTokenPlayerType` — used for the primary stream when - /// ad blocking is enabled. - static const forceAccessTokenPlayerType = 'popout'; - - /// vaft `BackupPlayerTypes` order when the primary playlist still has ads. - static const backupPlayerTypes = [ - '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 f3cc787d..1028c216 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -145,29 +145,17 @@ class TwitchApi extends BaseApiClient { return data['access_token'] as String; } - /// Validates [token] with Twitch. - /// - /// Returns token info (scopes + optional `user_id`) when valid, or `null` - /// when invalid/expired. App-access tokens may omit `user_id` and return an - /// empty scope list. - Future validateToken({required String token}) async { + /// Returns a bool indicating the validity of the given token. + Future validateToken({required String token}) async { try { - final data = await get( + 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, - ); + return true; } on UnauthorizedException { // 401 -> token is invalid/expired (propagated from interceptor for validate requests) - return null; + return false; } on ApiException catch (e) { // Network/timeout/server errors -> treat as indeterminate, not invalid debugPrint('Token validation indeterminate: $e'); @@ -175,23 +163,11 @@ class TwitchApi extends BaseApiClient { } } - /// Returns a [StreamsTwitch] object that contains top streams and a cursor for further requests. - /// - /// [language] is an optional ISO 639-1 code (Helix accepts only one). - Future getTopStreams({ - String? cursor, - String? language, - int first = 100, - }) async { - final queryParams = { - 'first': first, - if (cursor != null) 'after': cursor, - if (language != null && language.isNotEmpty) 'language': language, - }; - + /// Returns a [StreamsTwitch] object that contains the top 20 streams and a cursor for further requests. + Future getTopStreams({String? cursor}) async { final data = await get( '/streams', - queryParameters: queryParams, + queryParameters: cursor != null ? {'after': cursor} : null, ); return StreamsTwitch.fromJson(data); @@ -202,7 +178,7 @@ class TwitchApi extends BaseApiClient { required String id, String? cursor, }) async { - final queryParams = {'user_id': id, 'first': '100'}; + final queryParams = {'user_id': id}; if (cursor != null) queryParams['after'] = cursor; final data = await get( @@ -218,7 +194,7 @@ class TwitchApi extends BaseApiClient { required String userId, String? cursor, }) async { - final queryParams = {'user_id': userId, 'first': '100'}; + final queryParams = {'user_id': userId, 'first': '20'}; if (cursor != null) queryParams['after'] = cursor; final data = await get( @@ -229,153 +205,19 @@ class TwitchApi extends BaseApiClient { return FollowedChannels.fromJson(data); } - /// Whether [userId] follows [broadcasterId], via Helix (read-only; create/delete - /// follows were removed from the public API). - Future 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, String? cursor, - String? language, - int first = 100, }) async { - final queryParams = { - 'game_id': gameId, - 'first': first, - if (cursor != null) 'after': cursor, - if (language != null && language.isNotEmpty) 'language': language, - }; + final queryParams = {'game_id': gameId}; + if (cursor != null) queryParams['after'] = cursor; final data = await get('/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( @@ -623,131 +465,4 @@ class TwitchApi extends BaseApiClient { return data['messages'] as JsonList; } - - /// Returns a list of broadcaster IDs for channels the authenticated user - /// moderates, following pagination so moderators of more than one page of - /// channels aren't silently truncated at the first 100. - Future> 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_gql_api.dart b/lib/apis/twitch_gql_api.dart deleted file mode 100644 index 3923d500..00000000 --- a/lib/apis/twitch_gql_api.dart +++ /dev/null @@ -1,559 +0,0 @@ -import 'dart:math'; - -import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; -import 'package:frosty/apis/base_api_client.dart'; -import 'package:frosty/apis/twitch_integrity.dart'; -import 'package:frosty/constants.dart'; -import 'package:frosty/models/playback_access_token.dart'; -import 'package:frosty/models/stream.dart'; - -/// Viewer sort for GQL directory / tag stream lists. -enum StreamViewerSort { - viewersDesc, - viewersAsc; - - String get gqlValue => switch (this) { - StreamViewerSort.viewersDesc => 'VIEWER_COUNT', - StreamViewerSort.viewersAsc => 'VIEWER_COUNT_ASC', - }; -} - -/// Twitch GQL API client for playback tokens and directory queries -/// (freeform tags + sorted streams) that Helix does not expose. -/// -/// Uses the Twitch web client ID (not the app's registered client ID) -/// since the GQL endpoint requires it. -class TwitchGqlApi extends BaseApiClient { - TwitchGqlApi(Dio dio) : super(dio, 'https://gql.twitch.tv'); - - static const _gqlHeaders = { - '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 deleted file mode 100644 index efef0346..00000000 --- a/lib/apis/twitch_integrity.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'dart:convert'; - -/// Helpers for Twitch `Client-Integrity` tokens (`v4.public.*` / `v4.local.*`). -class TwitchIntegrity { - const TwitchIntegrity._(); - - /// Decodes the JSON payload embedded in an integrity token, if present. - static Map? 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 index 318a04ce..2dad5461 100644 --- a/lib/apis/unauthorized_interceptor.dart +++ b/lib/apis/unauthorized_interceptor.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:frosty/main.dart'; import 'package:frosty/screens/onboarding/login_webview.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; -import 'package:frosty/utils/twitch_oauth_scopes.dart'; import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:provider/provider.dart'; @@ -15,67 +14,16 @@ class UnauthorizedInterceptor extends Interceptor { @override void onError(DioException err, ErrorInterceptorHandler handler) { - // Check if this is a 401 Unauthorized error on a Twitch Helix/OAuth request - if (err.response?.statusCode == 401 && - _isTwitchHelixOrOAuth(err.requestOptions.uri)) { + // Check if this is a 401 Unauthorized error + if (err.response?.statusCode == 401) { // For token validation requests, let the error propagate so validateToken can handle it if (err.requestOptions.uri.path.endsWith('/validate')) { handler.next(err); return; } - final path = err.requestOptions.uri.path; - - // Background moderated-channels fetches must never nag — especially for - // core-only tokens after an app upgrade. - if (isTwitchModeratedChannelsPath(path)) { - handler.reject(err); - return; - } - - // Moderator actions without scopes → opt-in Enable flow, not a generic - // "missing permissions" re-login for core scopes. - if (isTwitchModeratorActionPath(path) && - !_authStore.hasModeratorScopes) { - _showAuthDialog( - title: 'Enable moderator tools', - message: enableModeratorToolsDialogMessage(), - primaryLabel: 'Enable', - onPrimary: () => openEnableModeratorTools(_authStore), - ); - handler.reject(err); - return; - } - - // Core-only messaging: if core scopes are present, prefer "Session expired" - // over "Missing permissions" (401 ≠ always missing scope). - final isLoggedIn = _authStore.isLoggedIn; - final missingCore = missingTwitchScopes( - granted: _authStore.grantedScopes, - ); - _showAuthDialog( - title: isLoggedIn && missingCore.isNotEmpty - ? 'Missing permissions' - : 'Session expired', - message: unauthorizedDialogMessage( - isLoggedIn: isLoggedIn, - grantedScopes: _authStore.grantedScopes, - ), - primaryLabel: 'Log in', - onPrimary: () { - final context = navigatorKey.currentContext; - if (context == null) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => Provider.value( - value: _authStore, - child: const LoginWebView(), - ), - ), - ); - }, - ); - handler.reject(err); + // For other requests, show login dialog and don't propagate + _showLoginDialog(); return; } @@ -83,20 +31,25 @@ class UnauthorizedInterceptor extends Interceptor { handler.next(err); } - void _showAuthDialog({ - required String title, - required String message, - required String primaryLabel, - required VoidCallback onPrimary, - }) { + void _showLoginDialog() { + // Use the global navigator key to show dialog without needing BuildContext final context = navigatorKey.currentContext; if (context == null) return; + + // Use a flag to prevent multiple dialogs if (_isDialogShowing) return; _isDialogShowing = true; + // Determine if user is logged in but missing scopes vs completely logged out + final isLoggedIn = _authStore.isLoggedIn; + final title = isLoggedIn ? 'Missing permissions' : 'Session expired'; + final message = isLoggedIn + ? 'Your session is missing permissions. Please log in again to continue.' + : 'Your session has expired. Please log in again to continue.'; + showDialog( context: context, - barrierDismissible: false, + barrierDismissible: false, // User must choose an action builder: (BuildContext dialogContext) { return FrostyDialog( title: title, @@ -105,39 +58,34 @@ class UnauthorizedInterceptor extends Interceptor { TextButton( onPressed: () { _isDialogShowing = false; - Navigator.of(dialogContext).pop(); + Navigator.of(dialogContext).pop(); // Close dialog }, child: const Text('Cancel'), ), FilledButton( onPressed: () { _isDialogShowing = false; - Navigator.of(dialogContext).pop(); - onPrimary(); + Navigator.of(dialogContext).pop(); // Close dialog + // Navigate to login WebView + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => Provider.value( + value: _authStore, + child: const LoginWebView(), + ), + ), + ); }, - child: Text(primaryLabel), + child: Text('Log in'), ), ], ); }, ).then((_) { + // Reset flag when dialog is dismissed _isDialogShowing = false; }); } static bool _isDialogShowing = false; - - bool _isTwitchHelixOrOAuth(Uri uri) { - final url = uri.toString(); - return url.startsWith('https://api.twitch.tv/helix') || - url.startsWith('https://id.twitch.tv/oauth2'); - } } - -/// Helix path for listing channels the user moderates. -bool isTwitchModeratedChannelsPath(String path) => - path.contains('/moderation/channels'); - -/// Helix paths for delete / ban / timeout / unban. -bool isTwitchModeratorActionPath(String path) => - path.contains('/moderation/chat') || path.contains('/moderation/bans'); diff --git a/lib/constants.dart b/lib/constants.dart index 0b977e2a..3fcfcd3a 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -3,18 +3,6 @@ import 'package:flutter/material.dart'; /// Twitch API client ID. const clientId = String.fromEnvironment('CLIENT_ID'); -/// Twitch GQL client ID (web player, used for playback access tokens). -const twitchGqlClientId = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - -/// SharedPreferences key for the per-channel last-selected stream quality. -/// A saved variant that doesn't exist on the target channel (e.g. 1080p on a -/// source-only channel) falls back to Auto via the quality-list lookup. -String lastStreamQualityKey(String userLogin) => - 'last_stream_quality_$userLogin'; - -/// Legacy global key. Cleared on startup during SharedPreferences init. -const kLegacyLastStreamQualityKey = 'last_stream_quality'; - /// Twitch API client secret. const secret = String.fromEnvironment('SECRET'); @@ -54,9 +42,6 @@ final regexLink = RegExp( caseSensitive: false, ); -/// Standard corner radius for card thumbnails and box art. -const kCardBorderRadius = BorderRadius.all(Radius.circular(8)); - /// The default badge width and height. const defaultBadgeSize = 18.0; diff --git a/lib/main.dart b/lib/main.dart index b0b87d00..9696c3d4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,30 +1,22 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'package:advanced_in_app_review/advanced_in_app_review.dart'; import 'package:app_links/app_links.dart'; -import 'package:dio/dio.dart'; -import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; -import 'package:firebase_performance/firebase_performance.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:frosty/apis/base_api_client.dart' show ApiException; import 'package:frosty/apis/bttv_api.dart'; import 'package:frosty/apis/dio_client.dart'; import 'package:frosty/apis/ffz_api.dart'; import 'package:frosty/apis/seventv_api.dart'; import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/apis/twitch_auth_interceptor.dart'; -import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/apis/unauthorized_interceptor.dart'; import 'package:frosty/cache_manager.dart'; -import 'package:frosty/constants.dart'; import 'package:frosty/firebase_options.dart'; import 'package:frosty/screens/channel/channel.dart'; import 'package:frosty/screens/home/home.dart'; @@ -39,25 +31,6 @@ import 'package:mobx/mobx.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -/// Errors that are expected on a mobile client — transient network/I/O, plugin -/// teardown races, and image fetch failures — and therefore shouldn't be -/// reported to Crashlytics as fatal crashes (which would tank the crash-free -/// metric and bucket unrelated errors together under the global handlers). -/// -/// This is a last-resort net. The layers that own these errors already record -/// them non-fatally with precise reasons (e.g. base_api_client, the IRC -/// websocket handlers); this only catches what escapes that local handling. -bool _isNonFatalError(Object error) { - return error is SocketException || - error is HttpException || - error is WebSocketChannelException || - error is ApiException || - error is MissingPluginException || - error is NetworkImageLoadException || - (error is DioException && error.type != DioExceptionType.badResponse); -} void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -66,44 +39,17 @@ void main() async { await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); - // Route uncaught framework errors to Crashlytics. Non-crashing UI errors - // (layout overflows, image-load failures) and other expected transient - // failures are recorded as non-fatal so they don't inflate the crash count. - FlutterError.onError = (details) { - // Run the cheap type checks first; only fall back to the diagnostics string - // (RenderFlex overflow has no typed signal) when those miss. - final isNonFatal = - _isNonFatalError(details.exception) || - details.exceptionAsString().contains('overflowed'); - if (isNonFatal) { - FirebaseCrashlytics.instance.recordFlutterError(details); - } else { - FirebaseCrashlytics.instance.recordFlutterFatalError(details); - } - }; + // Pass all uncaught "fatal" errors from the framework to Crashlytics + FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError; - // Pass uncaught asynchronous errors to Crashlytics. Expected transient errors - // are recorded non-fatally; everything else stays fatal. The reason gives the - // grouping engine a real signal so distinct errors don't collapse into one - // catch-all issue under this handler frame. + // Pass all uncaught asynchronous errors that aren't handled by the Flutter framework to Crashlytics PlatformDispatcher.instance.onError = (error, stack) { - FirebaseCrashlytics.instance.recordError( - error, - stack, - reason: error.runtimeType.toString(), - fatal: !_isNonFatalError(error), - ); + FirebaseCrashlytics.instance.recordError(error, stack, fatal: true); return true; }; final prefs = await SharedPreferences.getInstance(); - // One-time migration: the quality preference used to be stored under a - // single global key, which meant a saved 1080p60 from one channel could - // pin another channel to a variant it doesn't serve. Remove the legacy - // key; new writes go under per-channel keys. - await prefs.remove(kLegacyLastStreamQualityKey); - final firstRun = prefs.getBool('first_run') ?? true; // Workaround for clearing stored tokens on uninstall. @@ -131,19 +77,6 @@ void main() async { // Initialize a settings store from the settings JSON string. final settingsStore = SettingsStore.fromJson(jsonDecode(userSettings)); - // Disable Firebase collection in debug builds to avoid polluting production data. - // In release builds, respect the user's preference. - if (kDebugMode) { - FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(false); - FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(false); - FirebasePerformance.instance.setPerformanceCollectionEnabled(false); - } else { - final shareEnabled = settingsStore.shareCrashLogsAndAnalytics; - FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(shareEnabled); - FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(shareEnabled); - FirebasePerformance.instance.setPerformanceCollectionEnabled(shareEnabled); - } - // Create a MobX reaction that will save the settings on disk every time they are changed. autorun((_) => prefs.setString('settings', jsonEncode(settingsStore))); @@ -156,7 +89,6 @@ void main() async { final bttvApiService = BTTVApi(dioClient); final ffzApiService = FFZApi(dioClient); final sevenTVApiService = SevenTVApi(dioClient); - final twitchGqlApiService = TwitchGqlApi(dioClient); // Create global assets store (shared cache for global emotes/badges) final globalAssetsStore = GlobalAssetsStore( @@ -167,10 +99,7 @@ void main() async { ); // Create and initialize the authentication store - final authStore = AuthStore( - twitchApi: twitchApiService, - twitchGqlApi: twitchGqlApiService, - ); + final authStore = AuthStore(twitchApi: twitchApiService); // Add the auth interceptor to the Dio client after AuthStore creation dioClient.interceptors.add(TwitchAuthInterceptor(authStore)); @@ -179,10 +108,6 @@ void main() async { dioClient.interceptors.add(UnauthorizedInterceptor(authStore)); await authStore.init(); - FirebaseCrashlytics.instance.setCustomKey('is_logged_in', authStore.isLoggedIn); - if (authStore.isLoggedIn && authStore.user.details != null) { - FirebaseCrashlytics.instance.setUserIdentifier(authStore.user.details!.id); - } runApp( MultiProvider( @@ -193,7 +118,6 @@ void main() async { Provider.value(value: bttvApiService), Provider.value(value: ffzApiService), Provider.value(value: sevenTVApiService), - Provider.value(value: twitchGqlApiService), Provider.value(value: globalAssetsStore), ], child: MyApp(firstRun: firstRun), @@ -240,22 +164,20 @@ class _MyAppState extends State { colorSchemeSeed: Color(settingsStore.accentColor), ); - return MaterialApp( - title: 'Frosty', - theme: themes.light, - darkTheme: themes.dark, - themeMode: settingsStore.themeType == ThemeType.system - ? ThemeMode.system - : settingsStore.themeType == ThemeType.light - ? ThemeMode.light - : ThemeMode.dark, - navigatorObservers: [ - FirebaseAnalyticsObserver( - analytics: FirebaseAnalytics.instance, - ), - ], - home: widget.firstRun ? const OnboardingIntro() : const Home(), - navigatorKey: navigatorKey, + return Provider( + create: (_) => themes, + child: MaterialApp( + title: 'Frosty', + theme: themes.light, + darkTheme: themes.dark, + themeMode: settingsStore.themeType == ThemeType.system + ? ThemeMode.system + : settingsStore.themeType == ThemeType.light + ? ThemeMode.light + : ThemeMode.dark, + home: widget.firstRun ? const OnboardingIntro() : const Home(), + navigatorKey: navigatorKey, + ), ); }, ); @@ -308,7 +230,6 @@ class _MyAppState extends State { 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, diff --git a/lib/models/irc.dart b/lib/models/irc.dart index ba00cb52..649da4a8 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -56,11 +56,6 @@ class IRCMessage { this.actionLabel, }); - /// The channel this message actually originated from. In Twitch shared - /// chat, `source-room-id` names the participant channel a message was - /// sent in; otherwise it's just this connection's own room. - String? get sourceChannelId => tags['source-room-id'] ?? tags['room-id']; - /// Applies the given CLEARCHAT message to the provided messages and buffer. static void clearChat({ required List messages, @@ -181,9 +176,7 @@ class IRCMessage { size: badgeSize, color: Theme.of(context).iconTheme.color?.withValues(alpha: 0.5) ?? - Theme.of(context).colorScheme.onSurfaceVariant.withValues( - alpha: 0.5, - ), + Colors.grey.withValues(alpha: 0.5), ), ), ), @@ -191,6 +184,7 @@ class IRCMessage { span.add(const TextSpan(text: ' ')); } + final sourceChannelId = tags['source-room-id'] ?? tags['room-id']; final sourceChannelUser = channelIdToUserTwitch?[sourceChannelId]; if (sourceChannelUser != null) { final isCurrentChannel = sourceChannelId == currentChannelId; @@ -448,9 +442,8 @@ class IRCMessage { bool launchExternal, TextStyle? textStyle, void Function(String)? onTapPingedUser, - void Function()? onTapDeletedMessage, { - Color? linkColor, - }) { + void Function()? onTapDeletedMessage, + ) { if (!showMessage) { span.add( TextSpan( @@ -500,7 +493,6 @@ class IRCMessage { emoteScale, textStyle, launchExternal, - linkColor: linkColor, ); } else { localSpan.add( @@ -532,7 +524,6 @@ class IRCMessage { _createTextSpan( text: word, style: textStyle, - linkColor: linkColor, launchExternal: launchExternal, onTapPingedUser: onTapPingedUser, ), @@ -559,9 +550,8 @@ class IRCMessage { double emoteSize, double emoteScale, TextStyle? textStyle, - bool launchExternal, { - Color? linkColor, - }) { + bool launchExternal, + ) { final emoteStack = []; var index = startIndex; @@ -641,7 +631,6 @@ class IRCMessage { _createTextSpan( text: words[index], style: textStyle, - linkColor: linkColor, launchExternal: launchExternal, ), ); @@ -667,31 +656,16 @@ class IRCMessage { bool showMessage = true, Map? 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); + _addTimestamp(span, style, timestamp); _addHistoricalAndChannelBadges( context, span, @@ -700,6 +674,7 @@ class IRCMessage { currentChannelId, ); + final isHistorical = tags['historical'] == '1'; _addUserBadges( context, span, @@ -727,7 +702,6 @@ class IRCMessage { textStyle, onTapPingedUser, onTapDeletedMessage, - linkColor: linkColor, ); return span; @@ -836,7 +810,6 @@ class IRCMessage { required String text, required bool launchExternal, TextStyle? style, - Color? linkColor, Function(String)? onTapPingedUser, }) { if (text.startsWith('@')) { @@ -854,9 +827,9 @@ class IRCMessage { return TextSpan( text: text, style: style?.copyWith( - color: linkColor, + color: Colors.blue, decoration: TextDecoration.underline, - decorationColor: linkColor, + decorationColor: Colors.blue, ), recognizer: TapGestureRecognizer() ..onTap = () => launchUrl( @@ -978,31 +951,22 @@ class IRCMessage { // Obtain the index to break off the tags. final tagAndIrcMessageDivider = whole.indexOf(' '); - // Without a separating space there are no tags to slice and no message body - // to parse — bail out rather than throw on the substring/index calls below. - if (tagAndIrcMessageDivider == -1) { - return IRCMessage(raw: whole, command: Command.none, tags: const {}); - } + // Get the tags substring and escape characters. + // IRC messages escape spaces with \s. + final tags = whole + .substring(1, tagAndIrcMessageDivider) + .replaceAll('\\s', ' '); - // Parse the IRCv3 tags into a map. The tags section is everything between - // the leading '@' and the first space. We split on the *raw* string: a - // literal ';' is always a separator because escaped semicolons inside a - // value are encoded as `\:` (unescaped per-value below). + // Next, parse and map the tags. final mappedTags = {}; - final rawTags = whole.substring(1, tagAndIrcMessageDivider); - for (final tag in rawTags.split(';')) { - // Split on the first '=' only — IRCv3 tag values may contain '='. - final equalsIndex = tag.indexOf('='); + // Loop through each tag and store their key value pairs into the map. + for (final tag in tags.split(';')) { + // Skip if the tag has no value. + if (tag.endsWith('=')) continue; - // Skip valueless tags. Per the IRCv3 spec a missing value (bare `key`) - // and an empty value (`key=`) are equivalent — live Twitch sends the - // latter, the recent-messages API the former. - if (equalsIndex == -1 || equalsIndex == tag.length - 1) continue; - - mappedTags[tag.substring(0, equalsIndex)] = _unescapeTagValue( - tag.substring(equalsIndex + 1), - ); + final tagSplit = tag.split('='); + mappedTags[tagSplit[0]] = tagSplit[1]; } // Now we'll parse the message itself. @@ -1016,29 +980,11 @@ class IRCMessage { // Index 3 and beyond is the :message (word by word) that is sent by the user or empty depending on the command final splitMessage = ircMessage.split(' '); - // A well-formed message is at least ` `. A malformed or - // truncated line (e.g. from the recent-messages history API) can split into - // a single token, which would otherwise throw a RangeError on the - // splitMessage[0]/[1] accesses below. - if (splitMessage.length < 2) { - return IRCMessage(raw: whole, command: Command.none, tags: mappedTags); - } - // If the username exists, set it. - // tmi.twitch.tv means the message was sent by Twitch rather than a user, so it is irrelevant. - final prefix = splitMessage[0]; - final String? user; - if (prefix == _twitchIrcServer) { - user = mappedTags['login']; - } else { - // Normal prefixes are `nick!user@host`. Fall back to the login tag (or - // null) if the expected `!` is absent so an unexpected prefix shape can - // never throw a RangeError and take down the whole message. - final bangIndex = prefix.indexOf('!'); - user = bangIndex == -1 - ? mappedTags['login'] - : prefix.substring(0, bangIndex); - } + // tmi.twitch.tv means the message was sent by Twitch rather than a user, so will be irrelevant. + final String? user = splitMessage[0] == _twitchIrcServer + ? mappedTags['login'] + : splitMessage[0].substring(0, splitMessage[0].indexOf('!')); // If there is an associated message, set it. String? message; @@ -1069,9 +1015,9 @@ class IRCMessage { indexBetweenIdAndPositions, ); - // Parse the range to extract the associated word. - // If there is more than one index, use the first. - // Otherwise, use the provided index. + // Parse the range in order to extract the associated word. + // If there are more than one indices, use the first one. + // Else, use the one provided indices. final String range; if (emoteIdAndPosition.contains(',')) { range = emoteIdAndPosition.substring( @@ -1199,59 +1145,13 @@ class IRCMessage { ); } - /// Unescapes an IRCv3 message-tag value. - /// - /// The spec defines these sequences: `\:` → `;`, `\s` → space, `\\` → `\`, - /// `\r` → CR, `\n` → LF. Any other `\x` collapses to `x`, and a lone trailing - /// backslash is dropped. Done in a single pass so sequences can't be - /// double-unescaped (e.g. `\\s` correctly yields `\s`, not a space). - static String _unescapeTagValue(String value) { - // Fast path: the vast majority of tag values contain no escape sequences. - if (!value.contains('\\')) return value; - - final buffer = StringBuffer(); - for (var i = 0; i < value.length; i++) { - if (value[i] != '\\') { - buffer.write(value[i]); - continue; - } - - // A trailing backslash with nothing after it is dropped. - if (i + 1 == value.length) break; - - switch (value[i + 1]) { - case ':': - buffer.write(';'); - break; - case 's': - buffer.write(' '); - break; - case '\\': - buffer.write('\\'); - break; - case 'r': - buffer.write('\r'); - break; - case 'n': - buffer.write('\n'); - break; - default: - buffer.write(value[i + 1]); - } - i++; // Skip the escaped character we just consumed. - } - return buffer.toString(); - } - factory IRCMessage.createNotice({ required String message, VoidCallback? actionCallback, String? actionLabel, }) => IRCMessage( raw: '', - tags: { - 'tmi-sent-ts': '${DateTime.now().millisecondsSinceEpoch}', - }, + tags: {}, command: Command.notice, message: message, actionCallback: actionCallback, @@ -1304,9 +1204,8 @@ class USERSTATE { raw: ircMessage.raw, color: ircMessage.tags['color'] ?? color, displayName: ircMessage.tags['display-name'] ?? displayName, - // Missing tags must be false — the old `!= '0'` default treated absence as mod. - mod: ircMessage.tags['mod'] == '1', - subscriber: ircMessage.tags['subscriber'] == '1', + mod: ircMessage.tags['mod'] == '0' ? false : true, + subscriber: ircMessage.tags['subscriber'] == '0' ? false : true, ); } diff --git a/lib/models/playback_access_token.dart b/lib/models/playback_access_token.dart deleted file mode 100644 index 8ca16364..00000000 --- a/lib/models/playback_access_token.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/foundation.dart'; - -class PlaybackAccessToken { - final String value; - final String signature; - - /// Whether Twitch will suppress ads for this session (Turbo, sub, etc.). - /// Defaults to false when the token payload can't be parsed. - final bool hideAds; - - /// Quality group names (e.g. "1080p60") that exist for this stream but are - /// restricted to subscribers, so they won't appear in the HLS playlist. - final List 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/stream.dart b/lib/models/stream.dart index 34a35c60..0f433ff6 100644 --- a/lib/models/stream.dart +++ b/lib/models/stream.dart @@ -15,14 +15,6 @@ class StreamTwitch { final String startedAt; final String thumbnailUrl; - /// ISO 639-1 broadcast language from Helix (`de`, `en`, …). Empty when unknown. - @JsonKey(defaultValue: '') - final String language; - - /// Freeform tags from Helix (e.g. `Deutsch`, `English`). - @JsonKey(defaultValue: []) - final List tags; - const StreamTwitch( this.userId, this.userLogin, @@ -32,10 +24,8 @@ class StreamTwitch { this.title, this.viewerCount, this.startedAt, - this.thumbnailUrl, [ - this.language = '', - this.tags = const [], - ]); + this.thumbnailUrl, + ); factory StreamTwitch.fromJson(Map json) => _$StreamTwitchFromJson(json); @@ -48,20 +38,6 @@ class StreamsTwitch { const StreamsTwitch(this.data, this.pagination); - 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, - ); - } + factory StreamsTwitch.fromJson(Map json) => + _$StreamsTwitchFromJson(json); } diff --git a/lib/models/stream.g.dart b/lib/models/stream.g.dart index 95f9bff3..fe2fe565 100644 --- a/lib/models/stream.g.dart +++ b/lib/models/stream.g.dart @@ -16,8 +16,6 @@ StreamTwitch _$StreamTwitchFromJson(Map json) => StreamTwitch( (json['viewer_count'] as num).toInt(), json['started_at'] as String, json['thumbnail_url'] as String, - json['language'] as String? ?? '', - (json['tags'] as List?)?.map((e) => e as String).toList() ?? [], ); StreamsTwitch _$StreamsTwitchFromJson(Map json) => diff --git a/lib/models/stream_languages.dart b/lib/models/stream_languages.dart deleted file mode 100644 index 455ccc60..00000000 --- a/lib/models/stream_languages.dart +++ /dev/null @@ -1,50 +0,0 @@ -/// ISO 639-1 codes offered in Settings for stream language filtering. -/// -/// Stored lowercase (Helix). GQL `Language` enums are the uppercase form. -class StreamLanguages { - StreamLanguages._(); - - static const all = <({String code, String label})>[ - (code: 'de', label: 'Deutsch'), - (code: 'en', label: 'English'), - (code: 'es', label: 'Español'), - (code: 'fr', label: 'Français'), - (code: 'it', label: 'Italiano'), - (code: 'pt', label: 'Português'), - (code: 'pl', label: 'Polski'), - (code: 'ru', label: 'Русский'), - (code: 'tr', label: 'Türkçe'), - (code: 'nl', label: 'Nederlands'), - (code: 'sv', label: 'Svenska'), - (code: 'no', label: 'Norsk'), - (code: 'da', label: 'Dansk'), - (code: 'fi', label: 'Suomi'), - (code: 'cs', label: 'Čeština'), - (code: 'hu', label: 'Magyar'), - (code: 'ro', label: 'Română'), - (code: 'uk', label: 'Українська'), - (code: 'ja', label: '日本語'), - (code: 'ko', label: '한국어'), - (code: 'zh', label: '中文'), - (code: 'ar', label: 'العربية'), - (code: 'th', label: 'ไทย'), - (code: 'vi', label: 'Tiếng Việt'), - (code: 'id', label: 'Bahasa Indonesia'), - (code: 'hi', label: 'हिन्दी'), - (code: 'other', label: 'Other'), - ]; - - static String labelFor(String code) { - for (final entry in all) { - if (entry.code == code) return entry.label; - } - return code; - } - - /// Helix / settings codes → GQL `Language` enum values. - static List toGqlEnums(Iterable codes) => codes - .map((c) => c.trim().toLowerCase()) - .where((c) => c.isNotEmpty) - .map((c) => c.toUpperCase()) - .toList(); -} diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index 1013812b..a4fa6b76 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -6,13 +6,9 @@ import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_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'; @@ -20,13 +16,12 @@ 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:provider/provider.dart'; import 'package:simple_pip_mode/actions/pip_actions_layout.dart'; import 'package:simple_pip_mode/pip_widget.dart'; /// Creates a widget that shows the video stream (if live) and chat of the given user. class VideoChat extends StatefulWidget { - static const routeName = 'VideoChat'; - final String userId; final String userName; final String userLogin; @@ -47,10 +42,11 @@ class _VideoChatState extends State final _videoKey = GlobalKey(); final _chatKey = GlobalKey(); - // PiP drag state — ValueNotifier avoids full widget rebuilds during drag. - final _pipDragDistance = ValueNotifier(0); + // PiP drag state - essential only + double _pipDragDistance = 0; bool _isPipDragging = false; - bool _isInPipTriggerZone = false; + bool _isInPipTriggerZone = + false; // Track when in trigger zone for haptic feedback // Divider drag state for synchronizing animation bool _isDividerDragging = false; @@ -63,8 +59,6 @@ class _VideoChatState extends State late AnimationController _animationController; late Animation _springBackAnimation; - bool? _isImmersiveSystemUiEnabled; - late final ChatTabsStore _chatTabsStore = ChatTabsStore( twitchApi: context.twitchApi, bttvApi: context.bttvApi, @@ -78,24 +72,13 @@ class _VideoChatState extends State 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, - ); + late final VideoStore _videoStore = VideoStore( + userLogin: widget.userLogin, + userId: widget.userId, + twitchApi: context.twitchApi, + authStore: context.authStore, + settingsStore: context.settingsStore, + ); @override void initState() { @@ -112,10 +95,12 @@ class _VideoChatState extends State CurvedAnimation(parent: _animationController, curve: Curves.easeOut), ); - // Drive the ValueNotifier from the spring-back animation so the - // AnimatedBuilder rebuilds without a full setState. + // Attach listener to the controller (not the derived animation) so it + // survives _springBackAnimation being reassigned in _animateSpringBack. _animationController.addListener(() { - _pipDragDistance.value = _springBackAnimation.value; + setState(() { + _pipDragDistance = _springBackAnimation.value; + }); }); // Register as observer for app lifecycle events @@ -132,58 +117,58 @@ class _VideoChatState extends State } void _handlePipDragStart(DragStartDetails details) { - if (_videoStore.isInPipMode || _videoStore.paused || _videoStore.loading) { - return; - } + // Disable drag gesture when already in PiP mode or video is not playing + if (_videoStore.isInPipMode || _videoStore.paused) return; - _animationController.stop(); - _pipDragDistance.value = 0; - _isInPipTriggerZone = false; + _animationController.stop(); // Stop any ongoing animation setState(() { _isPipDragging = true; + _pipDragDistance = 0; + _isInPipTriggerZone = false; }); } void _handlePipDragUpdate(DragUpdateDetails details) { - if (!_isPipDragging || - _videoStore.isInPipMode || - _videoStore.paused || - _videoStore.loading) { + if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) { return; } - // Update the ValueNotifier directly — only the ListenableBuilder rebuilds, - // not the entire channel scaffold. - _pipDragDistance.value = - (_pipDragDistance.value + details.delta.dy).clamp(0, _pipMaxDragDistance); + setState(() { + _pipDragDistance += details.delta.dy; + _pipDragDistance = _pipDragDistance.clamp(0, _pipMaxDragDistance); - final wasInTriggerZone = _isInPipTriggerZone; - _isInPipTriggerZone = _pipDragDistance.value >= _pipTriggerDistance; + // Check if we've entered or exited the trigger zone for haptic feedback + final wasInTriggerZone = _isInPipTriggerZone; + _isInPipTriggerZone = _pipDragDistance >= _pipTriggerDistance; - if (!wasInTriggerZone && _isInPipTriggerZone) { - HapticFeedback.mediumImpact(); - } else if (wasInTriggerZone && !_isInPipTriggerZone) { - HapticFeedback.lightImpact(); - } + // Provide haptic feedback when entering the trigger zone + if (!wasInTriggerZone && _isInPipTriggerZone) { + HapticFeedback.mediumImpact(); // Entering trigger zone + } + // Provide subtle haptic feedback when exiting the trigger zone + else if (wasInTriggerZone && !_isInPipTriggerZone) { + HapticFeedback.lightImpact(); // Exiting trigger zone + } + }); } void _handlePipDragEnd(DragEndDetails details) { - if (!_isPipDragging || - _videoStore.isInPipMode || - _videoStore.paused || - _videoStore.loading) { + if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) { return; } final velocity = details.velocity.pixelsPerSecond.dy; final shouldTriggerPip = - _pipDragDistance.value >= _pipTriggerDistance || velocity > 600; + _pipDragDistance >= _pipTriggerDistance || + velocity > 600; // Simple velocity threshold if (shouldTriggerPip) { + // Simple haptic feedback on success HapticFeedback.mediumImpact(); _videoStore.requestPictureInPicture(); _resetDragState(); } else { + // Animate back to original position _animateSpringBack(); } } @@ -194,16 +179,16 @@ class _VideoChatState extends State } void _resetDragState() { - _pipDragDistance.value = 0; - _isInPipTriggerZone = false; setState(() { _isPipDragging = false; + _pipDragDistance = 0; + _isInPipTriggerZone = false; }); } void _animateSpringBack() { - _springBackAnimation = - Tween(begin: _pipDragDistance.value, end: 0).animate( + _springBackAnimation = Tween(begin: _pipDragDistance, end: 0) + .animate( CurvedAnimation(parent: _animationController, curve: Curves.easeOut), ); @@ -213,45 +198,26 @@ class _VideoChatState extends State }); } - 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; + return AnimatedBuilder( + animation: _animationController, + builder: (context, _) { + final currentDragDistance = _isPipDragging + ? _pipDragDistance + : _springBackAnimation.value; final scaleFactor = 1.0 - (currentDragDistance / _pipMaxDragDistance * 0.1); + Widget content = child; + if (aspectRatio != null) { + content = AspectRatio(aspectRatio: aspectRatio, child: child); + } + return Transform.translate( offset: Offset(0, currentDragDistance), child: Transform.scale( @@ -263,18 +229,16 @@ class _VideoChatState extends State onPanUpdate: _handlePipDragUpdate, onPanEnd: _handlePipDragEnd, onPanCancel: _handlePipDragCancel, - child: animatedChild, + child: content, ), if (!_videoStore.isInPipMode) Positioned.fill( child: IgnorePointer( - ignoring: - !(_isPipDragging && currentDragDistance > 0), + ignoring: !(_isPipDragging && _pipDragDistance > 0), child: AnimatedOpacity( - opacity: - (_isPipDragging && currentDragDistance > 0) - ? 1.0 - : 0.0, + opacity: (_isPipDragging && _pipDragDistance > 0) + ? 1.0 + : 0.0, duration: const Duration(milliseconds: 150), curve: Curves.easeOut, child: Container( @@ -311,19 +275,7 @@ class _VideoChatState extends State 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, - ), + child: Video(key: _videoKey, videoStore: _videoStore), ); final overlay = GestureDetector( @@ -350,9 +302,7 @@ class _VideoChatState extends State settingsStore: settingsStore, ); - if (_videoStore.paused || - _videoStore.streamInfo == null || - _videoStore.loading) { + if (_videoStore.paused || _videoStore.streamInfo == null) { return videoOverlay; } @@ -372,49 +322,15 @@ class _VideoChatState extends State ), ); - 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 video = Observer( + builder: (context) { + return Stack( + children: [ + player, + if (_videoStore.settingsStore.showOverlay) overlay, + ], + ); + }, ); final chat = Observer( @@ -459,11 +375,6 @@ class _VideoChatState extends State 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) { @@ -505,6 +416,10 @@ class _VideoChatState extends State builder: (context) { if (context.isLandscape && !settingsStore.landscapeForceVerticalChat) { + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.immersiveSticky, + ); + final landscapeChat = AnimatedContainer( curve: Curves.ease, duration: _isDividerDragging @@ -530,7 +445,11 @@ class _VideoChatState extends State ).dark, child: DefaultTextStyle( style: context.defaultTextStyle.copyWith( - color: context.frostyColors.overlayOnSurface, + color: context + .watch() + .dark + .colorScheme + .onSurface, ), child: landscapeChat, ), @@ -625,114 +544,124 @@ class _VideoChatState extends State ) : SafeArea( bottom: false, - left: - settingsStore.landscapeCutout != + left: settingsStore.landscapeCutout != LandscapeCutoutType.left && settingsStore.landscapeCutout != LandscapeCutoutType.both, - right: - settingsStore.landscapeCutout != + 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; + 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, - ); + // 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; - }); - }, - ), - ); + 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, - ), - ], - ); - }, - ), - ) + 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), ); } + + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: SystemUiOverlay.values, + ); return SafeArea( // Keep chat-only scrolling under the blurred app bar, but // ensure video does not bleed into the system status bar. @@ -745,15 +674,7 @@ class _VideoChatState extends State if (settingsStore.showVideo) ...[ AspectRatio( aspectRatio: 16 / 9, - child: SizedBox(), - ), - ChannelInfoHeader( - videoStore: _videoStore, - authStore: context.authStore, - userId: widget.userId, - displayName: widget.userName, - isInSharedChatMode: - _chatStore.isInSharedChatMode, + child: Container(), // Placeholder for video space ), ], Expanded(child: chat), @@ -780,16 +701,9 @@ class _VideoChatState extends State // If on Android, use PiPSwitcher to enable PiP functionality. if (Platform.isAndroid) { - // Both players need this — the WebView store used to be skipped by a - // NativeVideoPlayerInterface type check, so it never learned it was in - // PiP on Android. - void notifyPip(bool isInPip) => _videoStore.handleAndroidPipChanged(isInPip); - return PipWidget( pipLayout: PipActionsLayout.mediaOnlyPause, onPipAction: (_) => _videoStore.handlePausePlay(), - onPipEntered: () => notifyPip(true), - onPipExited: () => notifyPip(false), pipChild: player, child: videoChat, ); @@ -808,7 +722,6 @@ class _VideoChatState extends State _videoStore.dispose(); _animationController.dispose(); - _pipDragDistance.dispose(); SystemChrome.setEnabledSystemUIMode( SystemUiMode.manual, diff --git a/lib/screens/channel/chat/chat.dart b/lib/screens/channel/chat/chat.dart index 80ceda50..99da7004 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -1,21 +1,14 @@ import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart'; import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; -import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_bottom_bar.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_message.dart'; -import 'package:frosty/utils.dart'; import 'package:frosty/utils/context_extensions.dart'; import 'package:frosty/widgets/frosty_page_view.dart'; import 'package:frosty/widgets/frosty_scrollbar.dart'; -/// ~2x default to keep richer message widgets (emotes, badges, replies) built -/// ahead of fast scroll-back without holding the entire history. -const _chatCacheExtent = ScrollCacheExtent.pixels(500.0); - class Chat extends StatelessWidget { final ChatStore chatStore; final EdgeInsetsGeometry? listPadding; @@ -24,20 +17,13 @@ class Chat extends StatelessWidget { /// Passes this to ChatBottomBar for the ChatDetails menu. final VoidCallback onAddChat; - /// When set, the widget operates in merged mode: messages come from all tabs - /// and the active tab determines the send target. - final ChatTabsStore? chatTabsStore; - const Chat({ super.key, required this.chatStore, this.listPadding, required this.onAddChat, - this.chatTabsStore, }); - bool get _isMerged => chatTabsStore != null; - @override Widget build(BuildContext context) { return Observer( @@ -82,63 +68,40 @@ class Chat extends StatelessWidget { ? 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, + return FrostyScrollbar( + controller: chatStore.scrollController, + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top, + bottom: + chatStore.bottomBarHeight + bottomPadding, + ), + child: Observer( + builder: (context) { + return ListView.builder( + reverse: true, + padding: (listPadding ?? EdgeInsets.zero) + .add( + EdgeInsets.only( + bottom: + chatStore.bottomBarHeight + + bottomPadding, + ), + ), + addAutomaticKeepAlives: false, + controller: chatStore.scrollController, + itemCount: chatStore.renderMessages.length, + itemBuilder: (context, index) => + ChatMessage( + ircMessage: + chatStore.renderMessages[chatStore + .renderMessages + .length - + 1 - + index], + chatStore: chatStore, + ), ); - void onMove(PointerMoveEvent e) => _isMerged - ? chatTabsStore!.onMessageListPointerMove( - e.pointer, - e.position, - ) - : chatStore.onMessageListPointerMove( - e.pointer, - e.position, - ); - void onUp(int pointer) => _isMerged - ? chatTabsStore!.onMessageListPointerUp(pointer) - : chatStore.onMessageListPointerUp(pointer); - - return Listener( - onPointerDown: onDown, - onPointerMove: onMove, - onPointerUp: (e) => onUp(e.pointer), - onPointerCancel: (e) => onUp(e.pointer), - child: FrostyScrollbar( - controller: scrollController, - padding: EdgeInsets.only( - top: MediaQuery.of(context).padding.top, - bottom: - chatStore.bottomBarHeight + bottomPadding, - ), - child: Observer( - builder: (context) { - return _isMerged - ? _buildMergedList( - scrollController, - bottomPadding, - ) - : _buildNormalList( - scrollController, - bottomPadding, - ); - }, - ), + }, ), ); }, @@ -146,6 +109,19 @@ class Chat extends StatelessWidget { ), ), ), + // Prevents accidental chat scrolling when swiping down from the top edge + // to access system UI (Notification Center/Control Center) in landscape mode. + if (context.isLandscape) + Positioned( + top: 0, + left: 0, + right: 0, + height: 24, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onVerticalDragStart: (_) {}, + ), + ), Positioned( left: 0, right: 0, @@ -153,227 +129,116 @@ class Chat extends StatelessWidget { child: ChatBottomBar( chatStore: chatStore, onAddChat: onAddChat, - channelDisplayName: _isMerged - ? getReadableName( - chatTabsStore!.activeTab.displayName, - chatTabsStore!.activeTab.channelLogin, - ) - : null, ), ), - _buildResumeScrollButton(), + Builder( + builder: (context) { + final isHorizontalLandscape = + context.isLandscape && + !chatStore.settings.landscapeForceVerticalChat; + return AnimatedPadding( + duration: const Duration(milliseconds: 200), + padding: EdgeInsets.only( + left: 4, + top: 4, + right: 4, + bottom: + chatStore.bottomBarHeight + + (chatStore.assetsStore.showEmoteMenu || + isHorizontalLandscape + ? 0 + : MediaQuery.of(context).padding.bottom), + ), + child: Observer( + builder: (_) => AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: chatStore.autoScroll + ? null + : ElevatedButton.icon( + onPressed: chatStore.resumeScroll, + icon: const Icon( + Icons.arrow_downward_rounded, + ), + label: Text( + chatStore.messageBuffer.isNotEmpty + ? '${chatStore.messageBuffer.length} new ${chatStore.messageBuffer.length == 1 ? 'message' : 'messages'}' + : 'Resume scroll', + style: const TextStyle( + fontFeatures: [ + FontFeature.tabularFigures(), + ], + ), + ), + ), + ), + ), + ); + }, + ), ], ), ), - _buildEmoteMenu(context), + AnimatedContainer( + curve: Curves.ease, + duration: const Duration(milliseconds: 200), + height: chatStore.assetsStore.showEmoteMenu + ? context.screenHeight / (context.isPortrait ? 3 : 2) + : 0, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 100), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: chatStore.assetsStore.showEmoteMenu + ? ClipRect( + child: Column( + children: [ + const Divider(), + Expanded( + child: FrostyPageView( + headers: [ + 'Recent', + if (chatStore.settings.showTwitchEmotes) + 'Twitch', + if (chatStore.settings.show7TVEmotes) '7TV', + if (chatStore.settings.showBTTVEmotes) 'BTTV', + if (chatStore.settings.showFFZEmotes) 'FFZ', + ], + children: [ + RecentEmotesPanel(chatStore: chatStore), + if (chatStore.settings.showTwitchEmotes) + EmoteMenuPanel( + chatStore: chatStore, + twitchEmotes: chatStore + .assetsStore + .userEmoteSectionToEmotes, + ), + ...[ + if (chatStore.settings.show7TVEmotes) + chatStore.assetsStore.sevenTVEmotes, + if (chatStore.settings.showBTTVEmotes) + chatStore.assetsStore.bttvEmotes, + if (chatStore.settings.showFFZEmotes) + chatStore.assetsStore.ffzEmotes, + ].map( + (emotes) => EmoteMenuPanel( + chatStore: chatStore, + emotes: emotes, + ), + ), + ], + ), + ), + ], + ), + ) + : null, + ), + ), ], ); }, ); } - - Widget _buildNormalList( - ScrollController scrollController, - double bottomPadding, - ) { - return ListView.builder( - reverse: true, - padding: (listPadding ?? EdgeInsets.zero).add( - EdgeInsets.only(bottom: chatStore.bottomBarHeight + bottomPadding), - ), - addAutomaticKeepAlives: false, - scrollCacheExtent: _chatCacheExtent, - controller: scrollController, - itemCount: chatStore.renderMessages.length, - itemBuilder: (context, index) { - final ircMessage = chatStore - .renderMessages[chatStore.renderMessages.length - 1 - index]; - // Key by message identity so the list reconciles by message rather - // than by slot. This keeps each message's element (and its gesture - // recognizers) bound to the same message when new messages arrive - // mid-interaction, fixing long-press landing on the wrong message. - return ChatMessage( - key: ObjectKey(ircMessage), - ircMessage: ircMessage, - chatStore: chatStore, - ); - }, - ); - } - - Widget _buildMergedList( - ScrollController scrollController, - double bottomPadding, - ) { - final mergedMessages = chatTabsStore!.mergedMessages; - final channelIdToUserTwitch = chatTabsStore!.mergedChannelIdToUserTwitch; - final currentChannelId = chatTabsStore!.activeTab.channelId; - - return ListView.builder( - reverse: true, - padding: (listPadding ?? EdgeInsets.zero).add( - EdgeInsets.only(bottom: chatStore.bottomBarHeight + bottomPadding), - ), - addAutomaticKeepAlives: false, - scrollCacheExtent: _chatCacheExtent, - controller: scrollController, - itemCount: mergedMessages.length, - itemBuilder: (context, index) { - final merged = mergedMessages[mergedMessages.length - 1 - index]; - return ChatMessage( - key: ObjectKey(merged.ircMessage), - ircMessage: merged.ircMessage, - chatStore: merged.chatStore, - inputChatStore: chatStore, - onActivateSourceTab: () { - final tabs = chatTabsStore!.tabs; - final idx = tabs.indexWhere( - (t) => t.channelId == merged.chatStore.channelId, - ); - if (idx != -1 && idx != chatTabsStore!.activeTabIndex) { - chatTabsStore!.setActiveTab(idx, silent: true); - } - }, - overrideChannelIdToUserTwitch: channelIdToUserTwitch, - overrideCurrentChannelId: currentChannelId, - ); - }, - ); - } - - Widget _buildResumeScrollButton() { - return Builder( - builder: (context) { - final isHorizontalLandscape = - context.isLandscape && - !chatStore.settings.landscapeForceVerticalChat; - return AnimatedPadding( - duration: const Duration(milliseconds: 200), - padding: EdgeInsets.only( - left: 4, - top: 4, - right: 4, - bottom: - chatStore.bottomBarHeight + - (chatStore.assetsStore.showEmoteMenu || isHorizontalLandscape - ? 0 - : MediaQuery.of(context).padding.bottom), - ), - child: Observer( - builder: (_) { - final isAutoScrolling = _isMerged - ? chatTabsStore!.mergedAutoScroll - : chatStore.autoScroll; - final bufferCount = _isMerged - ? chatTabsStore!.mergedBufferCount - : chatStore.messageBuffer.length; - final onResume = _isMerged - ? chatTabsStore!.resumeMergedScroll - : chatStore.resumeScroll; - - return AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeIn, - child: isAutoScrolling - ? null - : ElevatedButton.icon( - onPressed: onResume, - icon: const Icon(Icons.arrow_downward_rounded), - label: Text( - bufferCount > 0 - ? '$bufferCount new ${bufferCount == 1 ? 'message' : 'messages'}' - : 'Resume scroll', - style: const TextStyle( - fontFeatures: [FontFeature.tabularFigures()], - ), - ), - ), - ); - }, - ), - ); - }, - ); - } - - Widget _buildEmoteMenu(BuildContext context) { - final menuHeight = context.screenHeight / (context.isPortrait ? 3 : 2); - return AnimatedContainer( - curve: Curves.ease, - duration: const Duration(milliseconds: 200), - height: chatStore.assetsStore.showEmoteMenu ? menuHeight : 0, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 100), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeIn, - child: chatStore.assetsStore.showEmoteMenu - ? ClipRect( - // Lay the content out at its full target height regardless of - // the animated container's current height, then clip — the - // Column would otherwise overflow (RenderFlex error) as the - // height tweens toward zero and can no longer fit the Divider. - child: OverflowBox( - minHeight: 0, - maxHeight: menuHeight, - alignment: Alignment.topCenter, - child: Column( - children: [ - const Divider(), - Expanded( - child: FrostyPageView( - headers: [ - 'Recent', - if (chatStore.settings.showTwitchEmotes) 'Twitch', - if (chatStore.settings.show7TVEmotes) '7TV', - if (chatStore.settings.showBTTVEmotes) 'BTTV', - if (chatStore.settings.showFFZEmotes) 'FFZ', - ], - tabActions: { - if (chatStore.assetsStore.recentEmotes.isNotEmpty) - 0: IconButton( - onPressed: () { - chatStore.assetsStore.recentEmotes.clear(); - chatStore.updateNotification( - 'Recent emotes cleared', - ); - }, - icon: const Icon(Icons.delete_outline_rounded), - tooltip: 'Clear recent emotes', - iconSize: 20, - ), - }, - children: [ - RecentEmotesPanel(chatStore: chatStore), - if (chatStore.settings.showTwitchEmotes) - EmoteMenuPanel( - chatStore: chatStore, - twitchEmotes: chatStore - .assetsStore - .userEmoteSectionToEmotes, - ), - ...[ - if (chatStore.settings.show7TVEmotes) - chatStore.assetsStore.sevenTVEmotes, - if (chatStore.settings.showBTTVEmotes) - chatStore.assetsStore.bttvEmotes, - if (chatStore.settings.showFFZEmotes) - chatStore.assetsStore.ffzEmotes, - ].map( - (emotes) => EmoteMenuPanel( - chatStore: chatStore, - emotes: emotes, - ), - ), - ], - ), - ), - ], - ), - ), - ) - : null, - ), - ); - } } diff --git a/lib/screens/channel/chat/details/chat_details.dart b/lib/screens/channel/chat/details/chat_details.dart index 987e15bf..89c225d4 100644 --- a/lib/screens/channel/chat/details/chat_details.dart +++ b/lib/screens/channel/chat/details/chat_details.dart @@ -416,7 +416,6 @@ class _ChatDetailsState extends State { onTap: () => Navigator.push( context, MaterialPageRoute( - settings: const RouteSettings(name: Settings.routeName), builder: (context) => Settings(settingsStore: widget.chatStore.settings), ), diff --git a/lib/screens/channel/chat/details/chat_modes.dart b/lib/screens/channel/chat/details/chat_modes.dart index 403381dc..9e22f939 100644 --- a/lib/screens/channel/chat/details/chat_modes.dart +++ b/lib/screens/channel/chat/details/chat_modes.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/models/irc.dart'; -import 'package:frosty/utils/context_extensions.dart'; class ChatModes extends StatelessWidget { final ROOMSTATE roomState; @@ -28,7 +27,7 @@ class ChatModes extends StatelessWidget { label: 'Emote only', activeLabel: 'Emote only', isActive: true, - activeColor: context.frostyColors.emoteOnly, + activeColor: const Color(0xFFFFB74D), // Slightly lighter orange ), ), ); @@ -45,7 +44,7 @@ class ChatModes extends StatelessWidget { label: 'Follower only', activeLabel: 'Follower only', isActive: true, - activeColor: context.frostyColors.followersOnly, + activeColor: const Color(0xFFF44336), // Bright red duration: _getFollowersDuration(), ), ), @@ -63,7 +62,7 @@ class ChatModes extends StatelessWidget { label: 'Slow mode', activeLabel: 'Slow mode', isActive: true, - activeColor: context.frostyColors.slowMode, + activeColor: const Color(0xFF2196F3), // Bright blue duration: _getSlowModeDuration(), ), ), @@ -81,7 +80,7 @@ class ChatModes extends StatelessWidget { label: 'Sub only', activeLabel: 'Sub only', isActive: true, - activeColor: context.frostyColors.subOnly, + activeColor: const Color(0xFF4CAF50), // Bright green ), ), ); @@ -98,7 +97,7 @@ class ChatModes extends StatelessWidget { label: 'Unique mode', activeLabel: 'Unique mode', isActive: true, - activeColor: context.frostyColors.uniqueMode, + activeColor: const Color(0xFFAB47BC), // Slightly lighter purple ), ), ); @@ -171,7 +170,7 @@ class ChatModes extends StatelessWidget { style: TextStyle( color: Theme.of( context, - ).colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ).colorScheme.onSurface.withValues(alpha: 0.6), ), ), ], 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 f4fb7897..9bd71aa1 100644 --- a/lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart +++ b/lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart @@ -8,6 +8,7 @@ import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/utils/context_extensions.dart'; import 'package:frosty/widgets/alert_message.dart'; import 'package:frosty/widgets/frosty_cached_network_image.dart'; +import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:provider/provider.dart'; class RecentEmotesPanel extends StatelessWidget { @@ -15,74 +16,122 @@ class RecentEmotesPanel extends StatelessWidget { const RecentEmotesPanel({super.key, required this.chatStore}); + Future _showClearDialog(BuildContext context) { + return showDialog( + context: context, + builder: (context) => FrostyDialog( + title: 'Clear recent emotes', + message: 'Are you sure you want to clear your recent emotes?', + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + chatStore.assetsStore.recentEmotes.clear(); + chatStore.updateNotification('Recent emotes cleared'); + + Navigator.pop(context); + }, + child: const Text('Clear'), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { return Observer( builder: (context) { - if (chatStore.assetsStore.recentEmotes.isEmpty) { - return const Center( - child: AlertMessage(message: 'No recent emotes'), - ); - } - return MediaQuery.removePadding( context: context, removeTop: true, - child: GridView.builder( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: context.isPortrait - ? 8 - : context.read().showVideo - ? 6 - : 16, - ), - itemBuilder: (context, index) { - final emote = chatStore.assetsStore.recentEmotes[index]; - final validEmotes = [ - ...chatStore.assetsStore.emoteToObject.values, - ...chatStore.assetsStore.userEmoteToObject.values, - ]; - final matchingEmotes = validEmotes.where( - (existingEmote) => - existingEmote.name == emote.name && - existingEmote.type == emote.type, - ); + child: CustomScrollView( + slivers: [ + if (chatStore.assetsStore.recentEmotes.isEmpty) + const SliverFillRemaining( + hasScrollBody: false, + child: AlertMessage(message: 'No recent emotes'), + ) + else + SliverGrid.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: context.isPortrait + ? 8 + : context.read().showVideo + ? 6 + : 16, + ), + itemBuilder: (context, index) { + final emote = chatStore.assetsStore.recentEmotes[index]; + final validEmotes = [ + ...chatStore.assetsStore.emoteToObject.values, + ...chatStore.assetsStore.userEmoteToObject.values, + ]; + final matchingEmotes = validEmotes.where( + (existingEmote) => + existingEmote.name == emote.name && + existingEmote.type == emote.type, + ); - return InkWell( - onTap: matchingEmotes.isNotEmpty - ? () => chatStore.addEmote(emote) - : null, - onLongPress: () { - HapticFeedback.lightImpact(); + return InkWell( + onTap: matchingEmotes.isNotEmpty + ? () => chatStore.addEmote(emote) + : null, + onLongPress: () { + HapticFeedback.lightImpact(); - IRCMessage.showEmoteDetailsBottomSheet( - context, - emote: emote, - launchExternal: chatStore.settings.launchUrlExternal, - ); - }, - child: Padding( - padding: const EdgeInsets.all(8), - child: Center( - child: FrostyCachedNetworkImage( - imageUrl: matchingEmotes.isNotEmpty - ? matchingEmotes.first.url - : emote.url, - color: matchingEmotes.isNotEmpty - ? null - : const Color.fromRGBO(255, 255, 255, 0.5), - colorBlendMode: matchingEmotes.isNotEmpty - ? null - : BlendMode.modulate, - height: - emote.height?.toDouble() ?? defaultEmoteSize, - width: emote.width?.toDouble(), + IRCMessage.showEmoteDetailsBottomSheet( + context, + emote: emote, + launchExternal: chatStore.settings.launchUrlExternal, + ); + }, + child: Padding( + padding: const EdgeInsets.all(8), + child: Center( + child: FrostyCachedNetworkImage( + imageUrl: matchingEmotes.isNotEmpty + ? matchingEmotes.first.url + : emote.url, + color: matchingEmotes.isNotEmpty + ? null + : const Color.fromRGBO(255, 255, 255, 0.5), + colorBlendMode: matchingEmotes.isNotEmpty + ? null + : BlendMode.modulate, + height: + emote.height?.toDouble() ?? defaultEmoteSize, + width: emote.width?.toDouble(), + ), + ), + ), + ); + }, + itemCount: chatStore.assetsStore.recentEmotes.length, + ), + SliverFillRemaining( + hasScrollBody: false, + child: Align( + alignment: Alignment.bottomCenter, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: SizedBox( + width: double.infinity, + child: TextButton.icon( + onPressed: () => _showClearDialog(context), + icon: const Icon(Icons.clear_all_rounded), + label: const Text('Clear recent emotes'), + ), + ), ), ), ), - ); - }, - itemCount: chatStore.assetsStore.recentEmotes.length, + ), + ], ), ); }, diff --git a/lib/screens/channel/chat/stores/banned_user_tracker.dart b/lib/screens/channel/chat/stores/banned_user_tracker.dart deleted file mode 100644 index 83a929b2..00000000 --- a/lib/screens/channel/chat/stores/banned_user_tracker.dart +++ /dev/null @@ -1,31 +0,0 @@ -/// Tracks which users are currently banned or timed out, learned from IRC -/// CLEARCHAT events, so the chat can offer an "unban / remove timeout" action -/// only when it's actually applicable. -/// -/// Keyed by Twitch user id. A value of null means a permanent ban; a non-null -/// [DateTime] is when a timeout expires. Pure logic (clock passed in) so it can -/// be unit-tested without the chat store. -class BannedUserTracker { - final _expiries = {}; - - /// 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 6a2187ca..c1ec05f7 100644 --- a/lib/screens/channel/chat/stores/chat_assets_store.dart +++ b/lib/screens/channel/chat/stores/chat_assets_store.dart @@ -130,7 +130,7 @@ abstract class ChatAssetsStoreBase with Store { @action Future init() async { - // Load SharedPreferences for recent emotes. + // Retrieve the instance that will allow us to retrieve local search history. final prefs = await SharedPreferences.getInstance(); _recentEmotes = @@ -290,18 +290,6 @@ abstract class ChatAssetsStoreBase with Store { await Future.wait(futures); } - /// Whether any shared chat participant assets have been merged in. - bool get hasLoadedSharedChatAssets => _loadedSharedChannelIds.isNotEmpty; - - /// Clears tracking of merged shared chat participant assets so that leaving - /// and later re-entering a shared chat session re-fetches cleanly. The caller - /// must re-fetch this channel's own assets afterwards (which replaces the - /// merged emote/badge maps). Must run within a MobX action. - void resetSharedChatAssets() { - _loadedSharedChannelIds.clear(); - sharedSevenTvSetIds.clear(); - } - /// Load and cache shared chat participants for a broadcaster. /// Returns the list of new participant broadcaster IDs (deduped). @action diff --git a/lib/screens/channel/chat/stores/chat_interaction_pause.dart b/lib/screens/channel/chat/stores/chat_interaction_pause.dart deleted file mode 100644 index 0d3456a9..00000000 --- a/lib/screens/channel/chat/stores/chat_interaction_pause.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:flutter/gestures.dart'; - -/// Decides whether the chat message list should hold buffered messages back -/// because the user is *holding* a message (a long-press candidate). -/// -/// Flushing buffered messages grows the reverse list and pushes the pressed -/// message up and out of the build range, which disposes its gesture recognizer -/// mid-press and cancels the long-press. Freezing the list while a finger rests -/// on a message keeps it under the finger so the press lands. -/// -/// Crucially, the freeze applies *only to a stationary finger*. The moment a -/// pointer travels past [kTouchSlop] it's a scroll, not a hold, so the chat -/// resumes flowing — otherwise freezing then batch-releasing mid-scroll makes -/// scrolling feel like it gets cancelled. This is the same threshold the -/// long-press recognizer uses to tell a hold from a drag. -/// -/// Tracks pointers individually so multi-touch resolves correctly: the chat -/// only resumes once every stationary pointer has lifted or started moving. -class ChatInteractionPause { - /// Down position of each active pointer, by pointer id. - final _origins = {}; - - /// 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 e7baa1c1..2ea1c347 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:convert'; -import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:frosty/apis/twitch_api.dart'; @@ -10,15 +9,11 @@ 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:web_socket_channel/io.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; part 'chat_store.g.dart'; @@ -30,7 +25,7 @@ abstract class ChatStoreBase with Store { /// The total maximum amount of messages in chat. static const _messageLimit = 5000; - /// The maximum amount of messages to render when autoscroll is enabled. + /// The maximum ammount of messages to render when autoscroll is enabled. static const _renderMessageLimit = 100; /// IRC commands that should bypass chat delay. @@ -70,42 +65,29 @@ abstract class ChatStoreBase with Store { 'msg_bad_characters', }; - /// Fallback height used to pad the message list for one frame, until the - /// real bottom bar reports its measured height via [setBottomBarHeight]. + /// Base height of the bottom bar (input field area). static const _baseBottomBarHeight = 68.0; - /// Server-initiated graceful reconnect notice. Twitch sends this untagged - /// before maintenance so clients can re-establish ahead of the close. - static const _serverReconnectNotice = ':tmi.twitch.tv RECONNECT'; + /// Height of the autocomplete bar (SizedBox + Divider). + static const _autocompleteHeight = 51.0; - /// Muted words pre-normalized (trimmed, lowercased, empties dropped) by a - /// reaction on the setting — avoids re-normalizing on every message. - var _normalizedMutedWords = []; + /// Height of the reply bar (Container + Divider). + static const _replyBarHeight = 41.0; - /// Whether a single IRC [line] should bypass the chat delay and be handled - /// immediately. Displayable chat content (PRIVMSG/USERNOTICE and the - /// moderation CLEARCHAT/CLEARMSG) is delayed, so it stays in sync with the - /// delayed video. Everything else is handled at once: control/state messages - /// (USERSTATE/ROOMSTATE/GLOBALUSERSTATE), NOTICE (so a send rejection surfaces - /// immediately instead of one chat-delay later), the reconnect notice, and - /// any untagged server line (PING keepalives, CAP, JOIN, the welcome banner) - /// — delaying those would stall the send-ack, drop the PONG, or never start - /// the buffer drain. - bool _lineShouldBypassDelay(String line) { - if (line.isEmpty || line == _serverReconnectNotice) return true; - // Displayable chat content is always IRCv3-tagged; an untagged line is - // never delayed. - if (!line.startsWith('@')) return true; - final parts = line.split(' '); - return parts.length >= 3 && _delayBypassCommands.contains(parts[2]); + /// Checks if IRC data contains a command that should bypass the chat delay. + /// Returns true if any message in the data contains a bypass command. + bool _shouldBypassDelay(String data) { + for (final message in data.trimRight().split('\r\n')) { + if (message.startsWith('@')) { + final parts = message.split(' '); + if (parts.length >= 3 && _delayBypassCommands.contains(parts[2])) { + return true; + } + } + } + return false; } - Duration get _chatDelay => - Duration(milliseconds: (settings.effectiveChatDelay * 1000).round()); - - bool get _hasActiveChatDelay => - settings.showVideo && _chatDelay > Duration.zero; - final TwitchApi twitchApi; /// The amount of messages to free (remove) when the [_messageLimit] is reached. @@ -144,12 +126,6 @@ abstract class ChatStoreBase with Store { static const _maxRetries = 5; - /// Ping interval for both the IRC and 7TV WebSocket sockets. Catches - /// zombie connections (silent network changes, OS suspends) without - /// waking the radio every 15s. Twitch's own recommended keepalive tops - /// out at 600s; community libs (e.g. dank-twitch-irc) sit around 30s. - static const _pingInterval = Duration(seconds: 60); - // The retry counter for exponential backoff. var _retries = 0; @@ -177,12 +153,6 @@ 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 = []; @@ -193,49 +163,14 @@ abstract class ChatStoreBase with Store { /// This is used as an optimization to prevent the list from being updated/shifted while the user is scrolling. 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; @@ -271,51 +206,21 @@ 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-range index error with sublist. - final base = (!_autoScroll || _messages.length < _renderMessageLimit) - ? _messages - : _messages.sublist(_messages.length - _renderMessageLimit); + // The second condition is to prevent an out of index error with sublist. + if (!_autoScroll || _messages.length < _renderMessageLimit) { + return _messages; + } - // Filter out messages from shared chat participant channels the user - // has hidden. Short-circuit when nothing is hidden (the common case). - if (sharedChatBubbles.hiddenChannelIds.isEmpty) return base; - return base.where((message) { - final sourceChannelId = message.sourceChannelId; - return sourceChannelId == null || - !sharedChatBubbles.hiddenChannelIds.contains(sourceChannelId); - }).toList(); + // When autoscroll is enabled, only show the first [_renderMessageLimit] messages. + // This will improve performance by only rendering a limited amount of messages + // instead of the entire history at all times. + return _messages.sublist(_messages.length - _renderMessageLimit); } /// If the chat should automatically scroll/jump to the latest message. @readonly var _autoScroll = true; - /// Tracks users banned/timed-out via CLEARCHAT so the moderation menu only - /// offers an unban action when it applies. - final _bannedUserTracker = BannedUserTracker(); - - /// Whether [userId] is currently banned or within an unexpired timeout - /// (as observed from CLEARCHAT in this session). - bool isUserBannedOrTimedOut(String userId) => - _bannedUserTracker.isBannedOrTimedOut(userId, DateTime.now()); - - /// Forget a user's ban state after a successful unban so the action hides. - void clearUserBanState(String userId) => _bannedUserTracker.clear(userId); - - /// Freezes the rendered list while the user is touching it, so a long-press - /// target can't scroll out from under the finger and get its gesture - /// recognizer disposed mid-press. Plain field (not observable): it only gates - /// the periodic [addMessages] flush, which reads it each 200ms tick. - final _interactionPause = ChatInteractionPause(); - - /// Hooks for the message list to report touch start/move/end (see [Chat]). - void onMessageListPointerDown(int pointer, Offset position) => - _interactionPause.pointerDown(pointer, position); - void onMessageListPointerMove(int pointer, Offset position) => - _interactionPause.pointerMove(pointer, position); - void onMessageListPointerUp(int pointer) => - _interactionPause.pointerUp(pointer); - @readonly var _inputText = ''; @@ -384,26 +289,25 @@ abstract class ChatStoreBase with Store { .toList(); } - /// Measured height of the rendered bottom bar (input plus any reply or - /// autocomplete overlays). The bar reports this via [setBottomBarHeight] so - /// the message list's bottom padding tracks the bar's *true* height — - /// including multi-line input growth and OS text scaling, which fixed - /// per-overlay constants silently undercount. - final _measuredBottomBarHeight = Observable(null); - - /// Current bottom bar height, used to dynamically pad the message list so the - /// bar never overlaps messages. Falls back to the single-line base height - /// until the real bar reports its first measurement. + /// Current bottom bar height based on visible overlays. + /// Used to dynamically adjust chat message list padding. @computed - double get bottomBarHeight => - _measuredBottomBarHeight.value ?? _baseBottomBarHeight; + double get bottomBarHeight { + var height = _baseBottomBarHeight; - /// Reports the bar's real rendered height. Sub-pixel changes are ignored to - /// avoid needless message-list relayouts. - void setBottomBarHeight(double height) { - final current = _measuredBottomBarHeight.value; - if (current != null && (current - height).abs() < 0.5) return; - runInAction(() => _measuredBottomBarHeight.value = height); + // Add reply bar height when replying to a message. + if (replyingToMessage != null) { + height += _replyBarHeight; + } + + // Add autocomplete bar height when showing matches. + if (settings.autocomplete && + ((_showEmoteAutocomplete && matchingEmotes.isNotEmpty) || + (_showMentionAutocomplete && matchingChatters.isNotEmpty))) { + height += _autocompleteHeight; + } + + return height; } ChatStoreBase({ @@ -422,17 +326,6 @@ abstract class ChatStoreBase with Store { reaction((_) => auth.isLoggedIn, (_) => _channel?.sink.close(1000)), ); - // Normalize muted words once per settings change instead of per message - // — the filter runs in the IRC hot path (50+ msgs/s in busy channels). - reactions.add( - reaction((_) => settings.mutedWords.toList(), (List words) { - _normalizedMutedWords = words - .map((word) => word.trim().toLowerCase()) - .where((word) => word.isNotEmpty) - .toList(); - }, fireImmediately: true), - ); - reactions.add( reaction( (_) => [ @@ -464,10 +357,9 @@ abstract class ChatStoreBase with Store { // Start chat delay countdown when toggling video on, cancel when off reactions.add( reaction((_) => settings.showVideo, (showVideo) { - if (showVideo && settings.effectiveChatDelay > 0) { + if (showVideo && settings.chatDelay > 0) { _startChatDelayCountdown(); } else if (!showVideo) { - _flushPendingDelayedCallbacks(); _cancelChatDelayCountdown(); _chatDelaySyncCompleted = false; } @@ -477,9 +369,8 @@ abstract class ChatStoreBase with Store { // Start chat delay countdown when chatDelay is set (for auto sync mode) // Cancel countdown if delay becomes 0 reactions.add( - reaction((_) => settings.effectiveChatDelay, (chatDelay) { + reaction((_) => settings.chatDelay, (chatDelay) { if (chatDelay == 0) { - _flushPendingDelayedCallbacks(); _cancelChatDelayCountdown(); _chatDelaySyncCompleted = false; } else if (settings.autoSyncChatDelay && @@ -501,10 +392,6 @@ abstract class ChatStoreBase with Store { connectToChat(); } - // Refresh which channels the user moderates on entry so mod actions reflect - // status granted/revoked since login. Fire-and-forget, like blocked users. - if (auth.isLoggedIn) auth.user.refreshModeratedChannels(); - // Tell the scrollController to determine when auto-scroll should be enabled or disabled. scrollController.addListener(() { // If the scroll position is at the latest message (maximum possible), enable autoscroll. @@ -543,71 +430,6 @@ abstract class ChatStoreBase with Store { }); } - 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]. @@ -618,29 +440,11 @@ abstract class ChatStoreBase with Store { // To account for this, split by CRLF, then loop and process each message. for (final message in data.trimRight().split('\r\n')) { // debugPrint('$message\n'); - if (message == _serverReconnectNotice) { - // Twitch is about to close the socket for maintenance — close it - // ourselves so the existing onDone path reconnects with no backoff - // (first retry is immediate) instead of waiting for the server to - // drop us mid-message. - _channel?.sink.close(1000); - continue; - } - if (message.startsWith('@')) { - // Parse defensively: a single malformed message must never abort the - // loop, or one bad frame would drop every other message batched with - // it (e.g. an entire recent-messages backlog). Skip and keep going. - final IRCMessage parsedIRCMessage; - try { - parsedIRCMessage = IRCMessage.fromString( - message, - userLogin: auth.user.details?.login, - ); - } catch (e) { - debugPrint('Failed to parse IRC message: $e'); - continue; - } + final parsedIRCMessage = IRCMessage.fromString( + message, + userLogin: auth.user.details?.login, + ); if (parsedIRCMessage.user != null) { chatDetailsStore.chatUsers.add(parsedIRCMessage.user!); @@ -658,17 +462,19 @@ abstract class ChatStoreBase with Store { continue; } - // Filter messages containing any muted words. Must use `continue` - // (not `return`) — a single IRC frame can batch many messages, and - // returning would drop the rest of the frame with the matched one. - if (parsedIRCMessage.message != null && - _normalizedMutedWords.isNotEmpty) { - final msg = parsedIRCMessage.message!.toLowerCase(); - final words = settings.matchWholeWord ? msg.split(' ') : null; - final muted = _normalizedMutedWords.any( - (word) => words != null ? words.contains(word) : msg.contains(word), - ); - if (muted) continue; + // Filter messages containing any muted words. + if (parsedIRCMessage.message != null) { + final List mutedWords = settings.mutedWords; + + // check if the message contains any of the muted words + for (final word in mutedWords) { + if (parsedIRCMessage.message! + .toLowerCase() + .split(settings.matchWholeWord ? ' ' : '') + .contains(word.toLowerCase())) { + return; + } + } } switch (parsedIRCMessage.command) { @@ -684,94 +490,48 @@ abstract class ChatStoreBase with Store { _sendingTimeoutTimer?.cancel(); _isWaitingForAck = false; toSend = null; - // Drop the retained text so a later USERSTATE can't render the - // rejected message as a deferred echo. - _pendingSentMessageText = null; // Show notification with Twitch's rejection message if (parsedIRCMessage.message != null) { - 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', + // Update shared chat mode based on source-room-id tag presence + final wasShared = _isInSharedChatMode; + _isInSharedChatMode = parsedIRCMessage.tags.containsKey( + 'source-room-id', + ); + // On transition into shared chat mode, fetch assets for participants. + if (!wasShared && _isInSharedChatMode) { + assetsStore.fetchSharedChatAssets( + channelId: channelId, + headers: auth.headersTwitch, + onEmoteError: (error) { + debugPrint(error.toString()); + return []; + }, + onBadgeError: (error) { + debugPrint(error.toString()); + return []; + }, + showTwitchEmotes: settings.showTwitchEmotes, + showTwitchBadges: settings.showTwitchBadges, + show7TVEmotes: settings.show7TVEmotes, + showBTTVEmotes: settings.showBTTVEmotes, + showFFZEmotes: settings.showFFZEmotes, + showFFZBadges: settings.showFFZBadges, ); - // On transition into shared chat mode, fetch assets for participants. - if (!wasShared && _isInSharedChatMode) { - assetsStore.fetchSharedChatAssets( - channelId: channelId, - headers: auth.headersTwitch, - onEmoteError: (error) { - debugPrint(error.toString()); - return []; - }, - 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, ircMessage: parsedIRCMessage, ); - // clearChat marks messages in place, which doesn't notify the - // observable list; reassign so renderMessages recomputes and the - // ban/timeout strike-through shows without waiting for the next - // message. - _messages = _messages.toList().asObservable(); break; case Command.clearMessage: IRCMessage.clearMessage( @@ -779,9 +539,6 @@ abstract class ChatStoreBase with Store { bufferedMessages: messageBuffer, ircMessage: parsedIRCMessage, ); - // Same as clearChat: force a re-render so the deleted message - // updates immediately rather than on the next incoming message. - _messages = _messages.toList().asObservable(); break; case Command.roomState: chatDetailsStore.roomState = chatDetailsStore.roomState @@ -790,13 +547,23 @@ abstract class ChatStoreBase with Store { case Command.userState: _userState = _userState.fromIRCMessage(parsedIRCMessage); - // A USERSTATE following our PRIVMSG confirms the send. Finalize - // regardless of whether a local echo was pre-built — gating this on - // `toSend` left the player stuck "sending" (and firing a false - // "may not have been sent") whenever the message went out before - // this session's first USERSTATE populated `_userState`. - if (_isWaitingForAck) { - _finalizeSend(parsedIRCMessage.tags['id']); + // USERSTATE arrival confirms message was sent, even without id tag + if (toSend != null) { + final messageId = parsedIRCMessage.tags['id']; + if (messageId != null) { + toSend!.tags['id'] = messageId; + } else { + // Fallback ID for edge cases where Twitch doesn't provide one + toSend!.tags['id'] = + 'local-${DateTime.now().millisecondsSinceEpoch}'; + } + messageBuffer.add(toSend!); + toSend = null; + // Reset sending state and clear text/reply after successful confirmation + _sendingTimeoutTimer?.cancel(); + _isWaitingForAck = false; + textController.clear(); + replyingToMessage = null; } break; case Command.globalUserState: @@ -894,12 +661,9 @@ abstract class ChatStoreBase with Store { 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) { + List onBadgeError(dynamic error) { debugPrint(error.toString()); - return {}; + return []; } final baseAssets = assetsStore.assetsFuture( @@ -962,12 +726,14 @@ abstract class ChatStoreBase with Store { ); _sevenTVChannel?.sink.close(1000); - _clearPendingDelayedCallbacks(clearChat: false); - _sevenTVChannel = IOWebSocketChannel.connect( + _sevenTVChannel = WebSocketChannel.connect( Uri.parse('wss://events.7tv.io/v3'), - pingInterval: _pingInterval, ); + // Track the current connection to prevent stale delayed callbacks + final connectionId = DateTime.now().millisecondsSinceEpoch; + var currentConnectionId = connectionId; + void listener(dynamic data) { // debugPrint(data); final decoded = jsonDecode(data); @@ -1012,25 +778,22 @@ abstract class ChatStoreBase with Store { _sevenTVChannelListener?.cancel(); _sevenTVChannelListener = _sevenTVChannel?.stream.listen( (data) { - if (!_hasActiveChatDelay) { + if (!settings.showVideo || settings.chatDelay == 0) { listener(data); } else { - _enqueueDelayedCallback( - _pendingSevenTVCallbacks, - () => listener(data), - ); + final capturedConnectionId = currentConnectionId; + Future.delayed(Duration(seconds: settings.chatDelay.toInt()), () { + // Only process if this is still the active connection + if (capturedConnectionId == currentConnectionId) { + listener(data); + } + }); } }, - onError: (error) { - debugPrint('7TV events error: ${error.toString()}'); - FirebaseCrashlytics.instance.recordError( - error, - StackTrace.current, - reason: '7TV WebSocket error, channel=$channelName', - ); - }, + onError: (error) => debugPrint('7TV events error: ${error.toString()}'), onDone: () { - _clearPendingDelayedCallbacks(clearChat: false); + // Invalidate the current connection to cancel pending delayed callbacks + currentConnectionId = 0; debugPrint('7TV events done'); }, ); @@ -1040,66 +803,50 @@ abstract class ChatStoreBase with Store { @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'), - ); + getAssets(); // Cancel existing listener to prevent duplicate message processing _channelListener?.cancel(); - _clearPendingDelayedCallbacks(clearSevenTV: false); _channel?.sink.close(1000); - _channel = IOWebSocketChannel.connect( + _channel = WebSocketChannel.connect( Uri.parse('wss://irc-ws.chat.twitch.tv:443'), - pingInterval: _pingInterval, ); // Only show chat delay countdown on initial connection or video toggle, not on reconnects - if (!isReconnect && _hasActiveChatDelay) { + if (!isReconnect && settings.showVideo && settings.chatDelay > 0) { _startChatDelayCountdown(); } + // Track the current connection to prevent stale delayed callbacks + final connectionId = DateTime.now().millisecondsSinceEpoch; + var currentConnectionId = connectionId; + // Listen for new messages and forward them to the handler. _channelListener = _channel?.stream.listen( (data) { final dataStr = data.toString(); - // No active delay: process the whole frame immediately. - if (!_hasActiveChatDelay) { + // Process immediately if delay is disabled or command should bypass delay + if (!settings.showVideo || + settings.chatDelay == 0 || + _shouldBypassDelay(dataStr)) { _handleIRCData(dataStr); - return; - } - - // Dispatch each line individually so a bypass-eligible control message - // isn't dragged behind the delay by chat lines batched in the same - // frame — and, conversely, so a delayed chat line can't hold up a - // PING/USERSTATE that shares the frame. - for (final line in dataStr.split('\r\n')) { - final trimmed = line.trimRight(); - if (trimmed.isEmpty) continue; - if (_lineShouldBypassDelay(trimmed)) { - _handleIRCData(trimmed); - } else { - _enqueueDelayedCallback( - _pendingChatCallbacks, - () => _handleIRCData(trimmed), - ); - } + } else { + final capturedConnectionId = currentConnectionId; + Future.delayed(Duration(seconds: settings.chatDelay.toInt()), () { + // Only process if this is still the active connection + if (capturedConnectionId == currentConnectionId) { + _handleIRCData(dataStr); + } + }); } }, - onError: (error) { - debugPrint('Chat error: ${error.toString()}'); - FirebaseCrashlytics.instance.recordError( - error, - StackTrace.current, - reason: 'IRC WebSocket error, channel=$channelName', - ); - }, + onError: (error) => debugPrint('Chat error: ${error.toString()}'), onDone: () async { - _clearPendingDelayedCallbacks(clearSevenTV: false); + // Invalidate the current connection to cancel pending delayed callbacks + currentConnectionId = 0; // Mark connection as disconnected for UI _isConnected = false; @@ -1108,23 +855,12 @@ abstract class ChatStoreBase with Store { _cancelChatDelayCountdown(); _chatDelaySyncCompleted = false; - // Reset send state — any in-flight message is unrecoverable across - // a disconnect, so don't leave the user blocked waiting for an ack - // that will never arrive. - _sendingTimeoutTimer?.cancel(); - _isWaitingForAck = false; - toSend = null; - _pendingSentMessageText = null; - if (_shouldDisconnect) { _sevenTVChannel?.sink.close(1000); return; } 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!); @@ -1150,9 +886,6 @@ abstract class ChatStoreBase with Store { // Increment the retry count. _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. @@ -1253,13 +986,7 @@ abstract class ChatStoreBase with Store { @action void addMessages() { - _flushDueDelayedCallbacks(); - - // Hold messages back while the user is touching the list so the pressed - // message stays put (and alive) for the duration of a long-press. - if (!_autoScroll || _interactionPause.isPaused || messageBuffer.isEmpty) { - return; - } + if (!_autoScroll || messageBuffer.isEmpty) return; _messages.addAll(messageBuffer); messageBuffer.clear(); @@ -1290,7 +1017,7 @@ abstract class ChatStoreBase with Store { messageBuffer.clear(); } - var remainingSeconds = _chatDelay.inSeconds; + var remainingSeconds = settings.chatDelay.toInt(); // Create and store reference to the countdown message _countdownMessage = IRCMessage.createNotice( @@ -1349,7 +1076,6 @@ abstract class ChatStoreBase with Store { // Prevent double-sending while waiting for server acknowledgment if (_isWaitingForAck) { - HapticFeedback.mediumImpact(); updateNotification('Please wait, sending previous message...'); return; } @@ -1375,93 +1101,48 @@ abstract class ChatStoreBase with Store { // Text field and reply state cleared on USERSTATE confirmation (not optimistically) // to preserve message text if Twitch rejects it (e.g., slow mode) - // Fallback in case the confirming USERSTATE never arrives. Twitch can omit - // or drop the USERSTATE that acknowledges a send — intermittently, and more - // often under a long chat delay — even though the message was delivered. - // A missing ack is therefore NOT evidence of failure, so rather than cry - // wolf with a "may not have been sent" error (which pushes the user to - // resend and double-post), optimistically finalize the send: render the - // local echo and clear the pending state. Genuine rejections almost always - // arrive first — the rejection NOTICE bypasses the chat delay, so it lands - // well within this window and clears the pending send before the timeout. A - // rejection arriving after the timeout has finalized is treated as sent (the - // echo stays), an accepted edge given how early NOTICEs arrive. Scaled by - // the chat delay so a legitimately delayed USERSTATE is given time to land. + // Start timeout timer in case server doesn't respond (network failure) + // Must be outside userStateString conditional to always have a safety net _sendingTimeoutTimer?.cancel(); - _sendingTimeoutTimer = Timer(const Duration(seconds: 10) + _chatDelay, () { - if (_shouldDisconnect || !_isWaitingForAck) return; - runInAction(() => _finalizeSend(null)); + _sendingTimeoutTimer = Timer(const Duration(seconds: 10), () { + // Guard against execution after disposal + if (_shouldDisconnect) return; + + if (_isWaitingForAck) { + _isWaitingForAck = false; + toSend = null; + updateNotification('Message may not have been sent. Please try again.'); + } }); - // Build the optimistic local echo from the current USERSTATE. If the - // message is sent before this session's first USERSTATE arrives, - // _buildLocalEcho returns null and the echo is built later, when the - // confirming USERSTATE lands (see the Command.userState handler). The raw - // text is retained so that deferred build is possible. - _pendingSentMessageText = message; - toSend = _buildLocalEcho(message); - } - - /// Renders the pending outgoing message's local echo (building it lazily if - /// it couldn't be built at send time) and clears all send-pending state. - /// Shared by the USERSTATE confirmation and the optimistic timeout fallback. - /// [serverMessageId] is the `id` tag from the confirming USERSTATE, or null - /// when finalizing optimistically without one. - void _finalizeSend(String? serverMessageId) { - toSend ??= _pendingSentMessageText != null - ? _buildLocalEcho(_pendingSentMessageText!) - : null; - if (toSend != null) { - // Prefer the server id; fall back to any id already set, then a local one. - toSend!.tags['id'] = - serverMessageId ?? - toSend!.tags['id'] ?? - 'local-${DateTime.now().millisecondsSinceEpoch}'; - messageBuffer.add(toSend!); - toSend = null; - } - _pendingSentMessageText = null; - _sendingTimeoutTimer?.cancel(); - _isWaitingForAck = false; - textController.clear(); - replyingToMessage = null; - } - - /// Builds the optimistic local echo of an outgoing [message] from the current - /// USERSTATE, or null if no USERSTATE has been received yet this session - /// (the echo can't be rendered without the user's badges/color/display-name). - IRCMessage? _buildLocalEcho(String message) { + // Obtain the logged-in user's appearance in chat with USERSTATE and create the full message to render. var userStateString = _userState.raw; - if (userStateString == null) return null; + if (userStateString != null) { + if (message.length > 3 && message.substring(0, 3) == '/me') { + userStateString += + ' :\x01ACTION ${message.replaceRange(0, 3, '').trim()}\x01'; + } else { + userStateString += + ' :${replyingToMessage?.tags['display-name'] != null ? '@${replyingToMessage!.tags['display-name']} ' : ''}${message.trim()}'; + } - if (message == '/me' || message.startsWith('/me ')) { - userStateString += - ' :\x01ACTION ${message.replaceRange(0, 3, '').trim()}\x01'; - } else { - userStateString += - ' :${replyingToMessage?.tags['display-name'] != null ? '@${replyingToMessage!.tags['display-name']} ' : ''}${message.trim()}'; + final userChatMessage = IRCMessage.fromString(userStateString); + userChatMessage.localEmotes?.addAll(assetsStore.userEmoteToObject); + if (auth.isLoggedIn && auth.user.details != null) { + userChatMessage.tags['user-id'] = auth.user.details!.id; + } + + if (replyingToMessage != null && replyingToMessage!.tags['id'] != null) { + userChatMessage.tags['reply-parent-msg-id'] = + replyingToMessage!.tags['id']!; + userChatMessage.tags['reply-parent-display-name'] = + replyingToMessage!.tags['display-name'] ?? ''; + userChatMessage.tags['reply-parent-msg-body'] = + replyingToMessage!.message ?? ''; + } + + toSend = userChatMessage; } - - final userChatMessage = IRCMessage.fromString(userStateString); - userChatMessage.localEmotes?.addAll(assetsStore.userEmoteToObject); - if (auth.isLoggedIn && auth.user.details != null) { - userChatMessage.tags['user-id'] = auth.user.details!.id; - } - - if (replyingToMessage != null && replyingToMessage!.tags['id'] != null) { - userChatMessage.tags['reply-parent-msg-id'] = - replyingToMessage!.tags['id']!; - userChatMessage.tags['reply-parent-display-name'] = - replyingToMessage!.tags['display-name'] ?? ''; - userChatMessage.tags['reply-parent-msg-body'] = - replyingToMessage!.message ?? ''; - } - - // Ensure a timestamp so merged-mode sorting works correctly. - userChatMessage.tags['tmi-sent-ts'] ??= - '${DateTime.now().millisecondsSinceEpoch}'; - - return userChatMessage; } /// Adds the given [emote] to the chat textfield. @@ -1500,6 +1181,9 @@ abstract class ChatStoreBase with Store { // when copying messages repeatedly. _notificationTimer?.cancel(); + // Provide subtle haptic feedback when notification is triggered + HapticFeedback.lightImpact(); + // Set the new notification message and create a new timer that will dismiss it after 3 seconds. _notification = notificationMessage; _notificationTimer = Timer( @@ -1551,16 +1235,12 @@ abstract class ChatStoreBase with Store { @action Future getRecentMessage() async { - try { - final recentMessages = await twitchApi.getRecentMessages( - userLogin: channelName, - ); + final recentMessages = await twitchApi.getRecentMessages( + userLogin: channelName, + ); - for (final message in recentMessages) { - _handleIRCData(message); - } - } catch (e) { - debugPrint('Failed to fetch recent messages: $e'); + for (final message in recentMessages) { + _handleIRCData(message); } } @@ -1568,10 +1248,6 @@ abstract class ChatStoreBase with Store { void dispose() { _shouldDisconnect = true; - _interactionPause.reset(); - - _clearPendingDelayedCallbacks(); - _messageBufferTimer?.cancel(); _notificationTimer?.cancel(); _sendingTimeoutTimer?.cancel(); @@ -1610,10 +1286,3 @@ abstract class ChatStoreBase with Store { 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_tabs_store.dart b/lib/screens/channel/chat/stores/chat_tabs_store.dart index ee095442..428ce76b 100644 --- a/lib/screens/channel/chat/stores/chat_tabs_store.dart +++ b/lib/screens/channel/chat/stores/chat_tabs_store.dart @@ -1,15 +1,9 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; import 'package:frosty/apis/bttv_api.dart'; import 'package:frosty/apis/ffz_api.dart'; import 'package:frosty/apis/seventv_api.dart'; import 'package:frosty/apis/twitch_api.dart'; -import 'package:frosty/models/irc.dart'; -import 'package:frosty/models/user.dart'; import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; -import 'package:frosty/screens/channel/chat/stores/chat_interaction_pause.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; @@ -56,10 +50,6 @@ class ChatTabInfo { /// The ChatStore for this tab. Null until the tab is activated (lazy loading). ChatStore? chatStore; - /// The id of the most recent message visible the last time the user viewed - /// this tab. Used to compute unread state — null means "no baseline yet". - String? lastSeenMessageId; - /// Whether this is the primary tab (first tab, cannot be removed). final bool isPrimary; @@ -75,14 +65,6 @@ class ChatTabInfo { }); } -/// A message paired with its source ChatStore for merged view rendering. -class MergedMessage { - final IRCMessage ircMessage; - final ChatStore chatStore; - - const MergedMessage({required this.ircMessage, required this.chatStore}); -} - /// Store that manages multiple chat tabs. class ChatTabsStore = ChatTabsStoreBase with _$ChatTabsStore; @@ -90,9 +72,6 @@ abstract class ChatTabsStoreBase with Store { /// Maximum number of tabs allowed. static const maxTabs = 10; - /// Max messages to render in merged view when autoscrolling. - static const _mergedRenderLimit = 100; - /// API services needed for creating ChatStore instances. final TwitchApi twitchApi; final BTTVApi bttvApi; @@ -129,175 +108,6 @@ abstract class ChatTabsStoreBase with Store { @computed bool get showTabBar => _tabs.length > 1; - /// Whether merged chat mode is active (session-only, not persisted). - @observable - var mergedMode = false; - - /// Channel profiles for badge rendering in merged view. - final _tabChannelProfiles = ObservableMap(); - - /// 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; @@ -314,9 +124,7 @@ abstract class ChatTabsStoreBase with Store { required String primaryDisplayName, }) { // Enable wakelock once for all chat tabs - if (settingsStore.keepScreenAwake) { - WakelockPlus.enable(); - } + WakelockPlus.enable(); // Initialize with the primary channel tab _addPrimaryTab( @@ -329,26 +137,6 @@ abstract class ChatTabsStoreBase with Store { if (settingsStore.persistChatTabs) { _restoreSecondaryTabs(primaryChannelId: primaryChannelId); } - - // Set up merged scroll listener once (reused across toggle cycles) - mergedScrollController.addListener(() { - if (!mergedScrollController.hasClients) return; - runInAction(() { - if (mergedScrollController.position.pixels <= 0) { - if (!_mergedAutoScroll) { - _mergedSnapshot = null; - _mergedAutoScroll = true; - } - } else if (mergedScrollController.position.pixels > 0) { - if (_mergedAutoScroll) { - // Capture snapshot so the list stays frozen while scrolling - _mergedSnapshot = _computeAllMergedMessages(); - _mergedSnapshotTotalCount = _totalMessageCount(); - _mergedAutoScroll = false; - } - } - }); - }); } /// Creates a ChatStore for a given channel. @@ -378,62 +166,6 @@ abstract class ChatTabsStoreBase with Store { ); } - /// Toggles merged chat mode. Only already-activated tabs are included; - /// tapping a lazy tab in merged mode activates it and adds it to the merge. - @action - void toggleMergedMode() { - mergedMode = !mergedMode; - if (mergedMode) { - // Reset scroll state - _mergedAutoScroll = true; - _mergedSnapshot = null; - // Fetch channel profiles for activated tabs only - for (final tab in _tabs) { - if (tab.chatStore != null) { - _fetchTabChannelProfile(tab); - } - } - // Start render timer — compute immediately then every 200ms - _refreshMergedMessages(); - _mergedRenderTimer?.cancel(); - _mergedRenderTimer = Timer.periodic( - const Duration(milliseconds: 200), - (_) => _refreshMergedMessages(), - ); - } else { - _mergedRenderTimer?.cancel(); - _mergedRenderTimer = null; - _mergedRenderedMessages = []; - } - } - - /// Re-enables auto-scroll and jumps to the latest message in merged view. - @action - void resumeMergedScroll() { - _mergedSnapshot = null; - _mergedAutoScroll = true; - // Refresh immediately so the list shows latest messages before the - // next timer tick. - _refreshMergedMessages(); - mergedScrollController.jumpTo(0); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mergedScrollController.hasClients) { - mergedScrollController.jumpTo(0); - } - }); - } - - /// Fetches the channel profile for badge rendering in merged view. - Future _fetchTabChannelProfile(ChatTabInfo tab) async { - if (_tabChannelProfiles.containsKey(tab.channelId)) return; - try { - final user = await twitchApi.getUser(id: tab.channelId); - _tabChannelProfiles[tab.channelId] = user; - } catch (e) { - debugPrint('Failed to fetch profile for ${tab.channelLogin}: $e'); - } - } - /// Activates a tab by creating its ChatStore if not already activated. @action void activateTab(int index) { @@ -542,14 +274,10 @@ abstract class ChatTabsStoreBase with Store { ), ); - // Switch to the new tab, snapshotting the outgoing tab's lastSeenMessageId. + // Switch to the new tab (this will activate it) final newIndex = _tabs.length - 1; - setActiveTab(newIndex); - - // If in merged mode, fetch the new tab's channel profile - if (mergedMode) { - _fetchTabChannelProfile(_tabs[newIndex]); - } + activateTab(newIndex); + activeTabIndex = newIndex; // Sync to settings for persistence _syncSecondaryTabsToSettings(); @@ -557,39 +285,6 @@ abstract class ChatTabsStoreBase with Store { return true; } - /// Deactivates a tab by disposing its ChatStore without removing the tab. - /// The tab stays in the tab bar but appears dimmed. Tapping reactivates it. - @action - void deactivateTab(int index) { - if (index < 0 || index >= _tabs.length) return; - final tab = _tabs[index]; - if (tab.isPrimary) return; - if (tab.chatStore == null) return; - - // If this is the active tab, switch to primary - if (index == activeTabIndex) { - activeTabIndex = 0; - } - - // Clean up merged mode state - if (mergedMode) { - _tabChannelProfiles.remove(tab.channelId); - _mergedSnapshot = null; - } - - // Dispose the ChatStore (closes IRC connection) - tab.chatStore!.dispose(); - tab.chatStore = null; - - // Exit merged mode if fewer than 2 tabs remain activated - if (mergedMode && _tabs.where((t) => t.chatStore != null).length < 2) { - mergedMode = false; - _mergedRenderTimer?.cancel(); - _mergedRenderTimer = null; - _mergedRenderedMessages = []; - } - } - /// Removes the tab at the given index. /// Returns true if removed, false if primary tab or invalid index. @action @@ -604,25 +299,12 @@ abstract class ChatTabsStoreBase with Store { return false; } - // Clean up merged mode state - _tabChannelProfiles.remove(_tabs[index].channelId); - // Clear stale snapshot that may reference this tab's disposed ChatStore - _mergedSnapshot = null; - // Dispose the ChatStore before removing (if activated) _tabs[index].chatStore?.dispose(); // Remove the tab _tabs.removeAt(index); - // Disable merged mode if only 1 tab remains - if (_tabs.length <= 1 && mergedMode) { - mergedMode = false; - _mergedRenderTimer?.cancel(); - _mergedRenderTimer = null; - _mergedRenderedMessages = []; - } - // Adjust active index if necessary if (activeTabIndex >= _tabs.length) { activeTabIndex = _tabs.length - 1; @@ -646,8 +328,10 @@ abstract class ChatTabsStoreBase with Store { // Primary tab (index 0) cannot be moved, and nothing can move before it if (oldIndex == 0 || newIndex == 0) return; - // onReorderItem already adjusts newIndex for the removed item, so no - // manual off-by-one correction is needed here. + // ReorderableListView passes newIndex as if item was already removed + if (oldIndex < newIndex) { + newIndex -= 1; + } if (oldIndex == newIndex) return; final tab = _tabs.removeAt(oldIndex); @@ -665,54 +349,12 @@ abstract class ChatTabsStoreBase with Store { _syncSecondaryTabsToSettings(); } - /// Returns the last message id in [messages] that has a non-null tag id, - /// or null if no such message exists. Skips system messages without ids. - String? _latestMessageId(List messages) { - for (var i = messages.length - 1; i >= 0; i--) { - final id = messages[i].tags['id']; - if (id != null) return id; - } - return null; - } - - /// True if [index] has unread messages since the user last viewed it. - /// Always false for the active tab and in merged mode. - bool hasUnreadMessages(int index) { - if (mergedMode) return false; - if (index == activeTabIndex) return false; - if (index < 0 || index >= _tabs.length) return false; - final tab = _tabs[index]; - final store = tab.chatStore; - if (store == null) return false; - final latest = _latestMessageId(store.messages); - if (latest == null) return false; - return latest != tab.lastSeenMessageId; - } - /// Sets the active tab to the given index. - /// - /// When [silent] is true, the current tab's draft text and reply state are - /// preserved. Used for programmatic switches (e.g., reply/paste routing in - /// merged mode) where wiping the draft would lose user work. @action - void setActiveTab(int index, {bool silent = false}) { + void setActiveTab(int index) { if (index >= 0 && index < _tabs.length) { - // Snapshot the outgoing tab's most recent message id so the unread - // dot for that tab clears, and only future messages count as unread. - if (index != activeTabIndex && - activeTabIndex >= 0 && - activeTabIndex < _tabs.length) { - final outgoing = _tabs[activeTabIndex]; - final outStore = outgoing.chatStore; - if (outStore != null) { - outgoing.lastSeenMessageId = _latestMessageId(outStore.messages); - } - } - - // Clear text input and emote menu when switching tabs. - // Skip in merged mode — tabs act as send-target selectors, not - // view switches, so draft/reply state should be preserved. - if (index != activeTabIndex && !silent && !mergedMode) { + // Clear text input and emote menu when switching tabs + if (index != activeTabIndex) { final currentStore = _tabs[activeTabIndex].chatStore; if (currentStore != null) { // Close emote menu if open @@ -729,24 +371,16 @@ abstract class ChatTabsStoreBase with Store { // Activate the target tab if not already activated activateTab(index); - if (mergedMode) { - _fetchTabChannelProfile(_tabs[index]); - } - activeTabIndex = index; } } /// Disposes all ChatStores and cleans up resources. void dispose() { - _mergedRenderTimer?.cancel(); - _mergedRenderTimer = null; - _interactionPause.reset(); for (final tab in _tabs) { tab.chatStore?.dispose(); } _tabs.clear(); - mergedScrollController.dispose(); // Disable wakelock once after all tabs disposed WakelockPlus.disable(); diff --git a/lib/screens/channel/chat/stores/chat_tabs_store.g.dart b/lib/screens/channel/chat/stores/chat_tabs_store.g.dart index 61bfea32..1408254f 100644 --- a/lib/screens/channel/chat/stores/chat_tabs_store.g.dart +++ b/lib/screens/channel/chat/stores/chat_tabs_store.g.dart @@ -56,31 +56,6 @@ mixin _$ChatTabsStore on ChatTabsStoreBase, Store { () => 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', @@ -120,109 +95,11 @@ mixin _$ChatTabsStore on ChatTabsStoreBase, Store { }); } - late final _$mergedModeAtom = Atom( - name: 'ChatTabsStoreBase.mergedMode', - context: context, - ); - - @override - bool get mergedMode { - _$mergedModeAtom.reportRead(); - return super.mergedMode; - } - - @override - set mergedMode(bool value) { - _$mergedModeAtom.reportWrite(value, super.mergedMode, () { - super.mergedMode = value; - }); - } - - late final _$_mergedAutoScrollAtom = Atom( - name: 'ChatTabsStoreBase._mergedAutoScroll', - context: context, - ); - - bool get mergedAutoScroll { - _$_mergedAutoScrollAtom.reportRead(); - return super._mergedAutoScroll; - } - - @override - bool get _mergedAutoScroll => mergedAutoScroll; - - @override - set _mergedAutoScroll(bool value) { - _$_mergedAutoScrollAtom.reportWrite(value, super._mergedAutoScroll, () { - super._mergedAutoScroll = value; - }); - } - - late final _$_mergedRenderedMessagesAtom = Atom( - name: 'ChatTabsStoreBase._mergedRenderedMessages', - context: context, - ); - - List 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( @@ -255,18 +132,6 @@ mixin _$ChatTabsStore on ChatTabsStoreBase, Store { } } - @override - void deactivateTab(int index) { - final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction( - name: 'ChatTabsStoreBase.deactivateTab', - ); - try { - return super.deactivateTab(index); - } finally { - _$ChatTabsStoreBaseActionController.endAction(_$actionInfo); - } - } - @override bool removeTab(int index) { final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction( @@ -292,12 +157,12 @@ mixin _$ChatTabsStore on ChatTabsStoreBase, Store { } @override - void setActiveTab(int index, {bool silent = false}) { + void setActiveTab(int index) { final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction( name: 'ChatTabsStoreBase.setActiveTab', ); try { - return super.setActiveTab(index, silent: silent); + return super.setActiveTab(index); } finally { _$ChatTabsStoreBaseActionController.endAction(_$actionInfo); } @@ -307,14 +172,10 @@ mixin _$ChatTabsStore on ChatTabsStoreBase, Store { String toString() { return ''' activeTabIndex: ${activeTabIndex}, -mergedMode: ${mergedMode}, activeTab: ${activeTab}, activeChatStore: ${activeChatStore}, canAddTab: ${canAddTab}, -showTabBar: ${showTabBar}, -mergedChannelIdToUserTwitch: ${mergedChannelIdToUserTwitch}, -mergedMessages: ${mergedMessages}, -mergedBufferCount: ${mergedBufferCount} +showTabBar: ${showTabBar} '''; } } diff --git a/lib/screens/channel/chat/stores/shared_chat_bubble_state.dart b/lib/screens/channel/chat/stores/shared_chat_bubble_state.dart deleted file mode 100644 index 6d18c655..00000000 --- a/lib/screens/channel/chat/stores/shared_chat_bubble_state.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:mobx/mobx.dart'; - -/// Tracks user-driven spotlight/hide selections for participant channels in -/// a Twitch shared chat session (multiple broadcasters merged onto one IRC -/// connection). Pure state so it can be unit-tested without the chat store. -/// -/// [ObservableSet] is reactive on its own, so [Observer] widgets reading -/// [focusedChannelIds]/[hiddenChannelIds] rebuild without any MobX codegen. -class SharedChatBubbleState { - /// Channel ids the user has spotlighted (kept unfaded). Empty means no - /// spotlight is active and nothing is faded. - final focusedChannelIds = ObservableSet(); - - /// 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/chat_bottom_bar.dart b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart index 7f5365f2..de1a7c2b 100644 --- a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart +++ b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart @@ -11,7 +11,6 @@ import 'package:frosty/utils/modal_bottom_sheet.dart'; import 'package:frosty/widgets/blurred_container.dart'; import 'package:frosty/widgets/chat_input/emote_text_span_builder.dart'; import 'package:frosty/widgets/frosty_cached_network_image.dart'; -import 'package:frosty/widgets/measure_size.dart'; class ChatBottomBar extends StatelessWidget { final ChatStore chatStore; @@ -20,14 +19,10 @@ class ChatBottomBar extends StatelessWidget { /// Passes this to ChatDetails to show "Add chat" option. final VoidCallback onAddChat; - /// When set, indicates merged mode — shown in hint text (e.g., "Chat in xQc"). - final String? channelDisplayName; - const ChatBottomBar({ super.key, required this.chatStore, required this.onAddChat, - this.channelDisplayName, }); @override @@ -68,11 +63,9 @@ class ChatBottomBar extends StatelessWidget { final isFullscreenOverlay = chatStore.settings.fullScreen && context.isLandscape; - final effectiveChatDelay = chatStore.settings.effectiveChatDelay; - // Check if chat delay is active (for indicator only, doesn't block input) final hasChatDelay = - chatStore.settings.showVideo && effectiveChatDelay > 0; + chatStore.settings.showVideo && chatStore.settings.chatDelay > 0; const loginTooltipMessage = 'Log in to chat'; @@ -306,15 +299,9 @@ class ChatBottomBar extends StatelessWidget { : isWaitingForAck ? 'Sending...' : chatStore.replyingToMessage != null - ? channelDisplayName != null - ? 'Reply in ${channelDisplayName!}' - : 'Reply' + ? 'Reply' : hasChatDelay - ? channelDisplayName != null - ? 'Chat in ${channelDisplayName!} (${effectiveChatDelay.toInt()}s delay)' - : 'Chat (${effectiveChatDelay.toInt()}s delay)' - : channelDisplayName != null - ? 'Chat in ${channelDisplayName!}' + ? 'Chat (${chatStore.settings.chatDelay.toInt()}s delay)' : 'Chat', ), controller: chatStore.textController, @@ -394,13 +381,6 @@ class ChatBottomBar extends StatelessWidget { final needsBottomPadding = !chatStore.assetsStore.showEmoteMenu && !isHorizontalLandscape; - // Measured once here; the list pads itself by this height so the bar - // never overlaps messages. Shared by both container branches below. - final measuredBar = MeasureSize( - onChange: (size) => chatStore.setBottomBarHeight(size.height), - child: bottomBarContent, - ); - return isFullscreenOverlay ? Padding( padding: EdgeInsets.only( @@ -408,7 +388,7 @@ class ChatBottomBar extends StatelessWidget { ? MediaQuery.of(context).padding.bottom : 0, ), - child: measuredBar, + child: bottomBarContent, ) : BlurredContainer( gradientDirection: GradientDirection.down, @@ -417,7 +397,7 @@ class ChatBottomBar extends StatelessWidget { ? MediaQuery.of(context).padding.bottom : 0, ), - child: measuredBar, + child: bottomBarContent, ); }, ); diff --git a/lib/screens/channel/chat/widgets/chat_message.dart b/lib/screens/channel/chat/widgets/chat_message.dart index 339254dc..13412a42 100644 --- a/lib/screens/channel/chat/widgets/chat_message.dart +++ b/lib/screens/channel/chat/widgets/chat_message.dart @@ -5,12 +5,9 @@ import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/models/irc.dart'; -import 'package:frosty/models/user.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart'; -import 'package:frosty/screens/channel/chat/widgets/moderation_actions.dart'; import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart'; -import 'package:frosty/utils/context_extensions.dart'; import 'package:frosty/utils/modal_bottom_sheet.dart'; import 'package:provider/provider.dart'; @@ -18,36 +15,16 @@ class ChatMessage extends StatelessWidget { final IRCMessage ircMessage; final ChatStore chatStore; final bool isModal; - final bool forceTimestamp; final bool showReplyHeader; final bool isInReplyThread; - /// Override for merged chat view — provides combined channel profiles. - final Map? overrideChannelIdToUserTwitch; - - /// Override for merged chat view — sets the active/send-target channel ID. - final String? overrideCurrentChannelId; - - /// In merged mode, the active tab's ChatStore for notifications and emote - /// menu. Composer actions (reply/paste) use [onActivateSourceTab] to switch - /// to the source tab first, then operate on [chatStore] directly. - final ChatStore? inputChatStore; - - /// In merged mode, switches the active tab to this message's source channel. - final VoidCallback? onActivateSourceTab; - const ChatMessage({ super.key, required this.ircMessage, required this.chatStore, this.isModal = false, - this.forceTimestamp = false, this.showReplyHeader = true, this.isInReplyThread = false, - this.overrideChannelIdToUserTwitch, - this.overrideCurrentChannelId, - this.inputChatStore, - this.onActivateSourceTab, }); void onTapName(BuildContext context) { @@ -68,7 +45,6 @@ class ChatMessage extends StatelessWidget { username: ircMessage.user!, userId: ircMessage.tags['user-id']!, displayName: ircMessage.tags['display-name']!, - onActivateSourceTab: onActivateSourceTab, ), ); } @@ -88,33 +64,23 @@ class ChatMessage extends StatelessWidget { username: user.login, userId: user.id, displayName: user.displayName, - onActivateSourceTab: onActivateSourceTab, ), ); } }); } - /// The store to use for notifications and emote menu in merged mode. - ChatStore get _effectiveInputStore => inputChatStore ?? chatStore; - Future copyMessage() async { await Clipboard.setData(ClipboardData(text: ircMessage.message ?? '')); - _effectiveInputStore.updateNotification('Message copied'); + chatStore.updateNotification('Message copied'); } void onLongPressMessage(BuildContext context, TextStyle defaultTextStyle) { HapticFeedback.lightImpact(); - // Allow the full menu for live messages and for ones marked deleted/cleared - // by a moderation action — those keep their tags (user-id, id), so a - // moderator can still act on them (e.g. remove a timeout). Everything else - // (notices, system messages) just copies. if (ircMessage.command != Command.privateMessage && - ircMessage.command != Command.userState && - ircMessage.command != Command.clearChat && - ircMessage.command != Command.clearMessage) { + ircMessage.command != Command.userState) { copyMessage(); return; } @@ -137,14 +103,9 @@ class ChatMessage extends StatelessWidget { badgeScale: chatStore.settings.badgeScale, launchExternal: chatStore.settings.launchUrlExternal, timestamp: chatStore.settings.timestampType, - forceTimestamp: forceTimestamp, - showHistoricalTimestamps: - chatStore.settings.showHistoricalTimestamps, channelIdToUserTwitch: - overrideChannelIdToUserTwitch ?? chatStore.assetsStore.channelIdToUserTwitch, - currentChannelId: - overrideCurrentChannelId ?? chatStore.channelId, + currentChannelId: chatStore.channelId, ), style: defaultTextStyle, ), @@ -163,14 +124,13 @@ class ChatMessage extends StatelessWidget { onTap: () async { await copyMessage(); - final effectiveChatDelay = chatStore.settings.effectiveChatDelay; - final hasChatDelay = - chatStore.settings.showVideo && effectiveChatDelay > 0; + chatStore.settings.showVideo && + chatStore.settings.chatDelay > 0; if (hasChatDelay) { - _effectiveInputStore.updateNotification( - 'Chatting is disabled due to message delay (${effectiveChatDelay.toInt()}s)', + chatStore.updateNotification( + 'Chatting is disabled due to message delay (${chatStore.settings.chatDelay.toInt()}s)', ); if (context.mounted) { Navigator.pop(context); @@ -178,10 +138,6 @@ class ChatMessage extends StatelessWidget { return; } - // In merged mode, switch to the source tab so the paste - // targets the correct channel's composer. - onActivateSourceTab?.call(); - // Paste the copied message into the text controller chatStore.textController.text = ircMessage.message ?? ''; chatStore.safeRequestFocus(); @@ -194,11 +150,6 @@ class ChatMessage extends StatelessWidget { ), ListTile( onTap: () { - // In merged mode, switch to the source tab so the reply - // targets the correct channel (reply-parent-msg-id is - // channel-scoped). - onActivateSourceTab?.call(); - chatStore.replyingToMessage = ircMessage; chatStore.safeRequestFocus(); Navigator.pop(context); @@ -206,14 +157,6 @@ class ChatMessage extends StatelessWidget { leading: const Icon(Icons.reply), title: const Text('Reply to message'), ), - if (ircMessage.tags['user-id'] != null) - ModerationActions( - chatStore: chatStore, - targetUserId: ircMessage.tags['user-id']!, - targetName: - ircMessage.tags['display-name'] ?? ircMessage.user ?? '', - messageId: ircMessage.tags['id'], - ), ], ), ); @@ -257,14 +200,9 @@ class ChatMessage extends StatelessWidget { badgeScale: chatStore.settings.badgeScale, launchExternal: chatStore.settings.launchUrlExternal, timestamp: chatStore.settings.timestampType, - forceTimestamp: forceTimestamp, - showHistoricalTimestamps: - chatStore.settings.showHistoricalTimestamps, channelIdToUserTwitch: - overrideChannelIdToUserTwitch ?? chatStore.assetsStore.channelIdToUserTwitch, - currentChannelId: - overrideCurrentChannelId ?? chatStore.channelId, + currentChannelId: chatStore.channelId, ), ), ); @@ -314,7 +252,7 @@ class ChatMessage extends StatelessWidget { ), ); } else if (shouldHighlightFirstMessage) { - highlightColor = context.frostyColors.highlightedMessage; + highlightColor = Colors.purple; messageHeaderIcon = Icon( Icons.auto_awesome_rounded, size: messageHeaderIconSize, @@ -328,7 +266,7 @@ class ChatMessage extends StatelessWidget { ), ); } else if (shouldHighlightMessage) { - highlightColor = context.frostyColors.highlightedMessage; + highlightColor = const Color(0xff9146ff); messageHeader = Text( 'Highlighted message', style: TextStyle( @@ -339,9 +277,7 @@ class ChatMessage extends StatelessWidget { } // If user is being mentioned in the message, highlight it red. - if (ircMessage.mention == true) { - highlightColor = context.colorScheme.error; - } + if (ircMessage.mention == true) highlightColor = Colors.red; if (messageHeader != null) { renderMessage = Column( @@ -416,14 +352,9 @@ class ChatMessage extends StatelessWidget { }, launchExternal: chatStore.settings.launchUrlExternal, timestamp: chatStore.settings.timestampType, - forceTimestamp: forceTimestamp, - showHistoricalTimestamps: - chatStore.settings.showHistoricalTimestamps, channelIdToUserTwitch: - overrideChannelIdToUserTwitch ?? chatStore.assetsStore.channelIdToUserTwitch, - currentChannelId: - overrideCurrentChannelId ?? chatStore.channelId, + currentChannelId: chatStore.channelId, ), ), ), @@ -470,7 +401,7 @@ class ChatMessage extends StatelessWidget { break; case Command.userNotice: if (chatStore.settings.showUserNotices) { - highlightColor = context.frostyColors.highlightedMessage; + highlightColor = const Color(0xff9146ff); Widget? messageHeaderIcon; Widget? messageHeader; @@ -512,14 +443,10 @@ class ChatMessage extends StatelessWidget { messageHeaderIcon = Icon( Icons.campaign_rounded, size: messageHeaderIconSize, - color: messageHeaderTextColor, ); - messageHeader = Text( + messageHeader = const Text( 'Announcement', - style: TextStyle( - fontWeight: messageHeaderFontWeight, - color: messageHeaderTextColor, - ), + style: TextStyle(fontWeight: messageHeaderFontWeight), ); } @@ -548,14 +475,9 @@ class ChatMessage extends StatelessWidget { badgeScale: chatStore.settings.badgeScale, launchExternal: chatStore.settings.launchUrlExternal, timestamp: chatStore.settings.timestampType, - forceTimestamp: forceTimestamp, - showHistoricalTimestamps: - chatStore.settings.showHistoricalTimestamps, channelIdToUserTwitch: - overrideChannelIdToUserTwitch ?? chatStore.assetsStore.channelIdToUserTwitch, - currentChannelId: - overrideCurrentChannelId ?? chatStore.channelId, + currentChannelId: chatStore.channelId, ), ), ), @@ -626,24 +548,15 @@ class ChatMessage extends StatelessWidget { child: dividedMessage, ); - final shouldFade = chatStore.shouldFadeMessage( - sourceChannelId: ircMessage.sourceChannelId, - overrideCurrentChannelId: overrideCurrentChannelId, - ); - - final fadedMessage = shouldFade - ? Opacity(opacity: 0.55, child: coloredMessage) - : coloredMessage; - final finalMessage = InkWell( onTap: () { FocusScope.of(context).unfocus(); - if (_effectiveInputStore.assetsStore.showEmoteMenu) { - _effectiveInputStore.assetsStore.showEmoteMenu = false; + if (chatStore.assetsStore.showEmoteMenu) { + chatStore.assetsStore.showEmoteMenu = false; } }, onLongPress: () => onLongPressMessage(context, defaultTextStyle), - child: fadedMessage, + child: coloredMessage, ); return finalMessage; diff --git a/lib/screens/channel/chat/widgets/chat_tabs.dart b/lib/screens/channel/chat/widgets/chat_tabs.dart index 490ce0df..3f81faff 100644 --- a/lib/screens/channel/chat/widgets/chat_tabs.dart +++ b/lib/screens/channel/chat/widgets/chat_tabs.dart @@ -3,11 +3,11 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:frosty/models/user.dart'; import 'package:frosty/screens/channel/chat/chat.dart'; import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart'; import 'package:frosty/screens/channel/chat/widgets/add_chat_dialog.dart'; import 'package:frosty/utils.dart'; +import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:frosty/widgets/profile_picture.dart'; /// Widget that displays multiple chat tabs with a tab bar. @@ -39,6 +39,7 @@ class ChatTabs extends StatelessWidget { ); if (!added && context.mounted) { + // If not added, it means the channel already exists (switched to it) ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Channel already open, switched to it'), @@ -49,36 +50,21 @@ class ChatTabs extends StatelessWidget { } } - /// Wraps a tab bar item (a tab chip or a shared-chat bubble) with its - /// trailing gap and centering, keyed for the reorderable list. - Widget _wrapItem({required Key key, required bool isLast, required Widget child}) { - return Padding( - key: key, - padding: EdgeInsets.only(right: isLast ? 0 : 4), - child: Center(child: child), - ); - } - @override Widget build(BuildContext context) { return Observer( builder: (context) { final tabs = chatTabsStore.tabs; final activeIndex = chatTabsStore.activeTabIndex; - final activeStore = chatTabsStore.activeChatStore; - // Twitch native shared chat participants (excluding the active - // channel itself, which is already shown as its own tab chip). - final sharedChatParticipants = activeStore.isInSharedChatMode - ? activeStore.assetsStore.channelIdToUserTwitch.entries - .where((entry) => entry.key != activeStore.channelId) - .toList() - : const >[]; - final showTabBar = - chatTabsStore.showTabBar || sharedChatParticipants.isNotEmpty; - final showMerge = tabs.where((t) => t.isActivated).length >= 2; + final showTabBar = chatTabsStore.showTabBar; + // Calculate extra top padding for tab bar when visible final tabBarHeight = showTabBar ? 48.0 : 0.0; + + // Get the top inset from listPadding (e.g., for AppBar in chat-only mode) final topInset = listPadding?.resolve(TextDirection.ltr).top ?? 0; + + // Adjust list padding to account for tab bar final adjustedPadding = listPadding != null ? listPadding!.add(EdgeInsets.only(top: tabBarHeight)) : EdgeInsets.only(top: tabBarHeight); @@ -88,43 +74,45 @@ class ChatTabs extends StatelessWidget { onPopInvokedWithResult: (didPop, _) { if (didPop) return; - // Android back gesture: dismiss overlays before navigating away. + // On Android back gesture, close overlays first before navigating back final activeStore = chatTabsStore.activeChatStore; + + // Priority 1: Close emote menu if (activeStore.assetsStore.showEmoteMenu) { activeStore.assetsStore.showEmoteMenu = false; return; } + + // Priority 2: Unfocus keyboard if (activeStore.textFieldFocusNode.hasFocus) { activeStore.unfocusInput(); return; } + + // Priority 3: Navigate back Navigator.of(context).pop(); }, child: Stack( children: [ + // Chat content with IndexedStack to preserve state Positioned.fill( - child: chatTabsStore.mergedMode - ? Chat( - chatStore: chatTabsStore.activeChatStore, - chatTabsStore: chatTabsStore, - listPadding: adjustedPadding, - onAddChat: () => _handleAddChat(context), - ) - : IndexedStack( - index: activeIndex, - children: tabs.map((tabInfo) { - if (tabInfo.chatStore == null) { - return const SizedBox.shrink(); - } - return Chat( - key: ValueKey(tabInfo.channelId), - chatStore: tabInfo.chatStore!, - listPadding: adjustedPadding, - onAddChat: () => _handleAddChat(context), - ); - }).toList(), - ), + child: IndexedStack( + index: activeIndex, + children: tabs.map((tabInfo) { + // Show placeholder for non-activated tabs + if (tabInfo.chatStore == null) { + return const SizedBox.shrink(); + } + return Chat( + key: ValueKey(tabInfo.channelId), + chatStore: tabInfo.chatStore!, + listPadding: adjustedPadding, + onAddChat: () => _handleAddChat(context), + ); + }).toList(), + ), ), + // Tab bar (only visible when more than 1 tab) if (showTabBar) Positioned( top: topInset, @@ -132,78 +120,30 @@ class ChatTabs extends StatelessWidget { right: 0, child: SizedBox( height: 48, - child: Stack( - children: [ - // Right-clip past the merge button so chips slide - // under its left half then disappear. - Positioned( - left: 0, - top: 0, - bottom: 0, - right: showMerge ? 33 : 0, - child: ReorderableListView.builder( - scrollDirection: Axis.horizontal, - itemCount: tabs.length + sharedChatParticipants.length, - padding: EdgeInsets.only( - left: 12, - right: showMerge ? 32 : 12, - ), - onReorderItem: (oldIndex, newIndex) { - // Shared chat bubbles are trailing, fixed-order - // items (not real tabs) — ignore any reorder - // that touches them. - if (oldIndex >= tabs.length || - newIndex >= tabs.length) { - return; - } - HapticFeedback.lightImpact(); - chatTabsStore.reorderTab(oldIndex, newIndex); - }, - proxyDecorator: (child, index, animation) { - return Material( - color: Colors.transparent, - child: child, - ); - }, - itemBuilder: (context, index) { - final isLast = - index == - tabs.length + - sharedChatParticipants.length - - 1; - if (index < tabs.length) { - final tabInfo = chatTabsStore.tabs[index]; - return _wrapItem( - key: ValueKey(tabInfo.channelId), - isLast: isLast, - child: _buildTab(context, index), - ); - } - - final participant = - sharedChatParticipants[index - tabs.length]; - return _wrapItem( - key: ValueKey('shared_${participant.key}'), - isLast: isLast, - child: _buildSharedChatBubble( - context, - participant.key, - participant.value, - ), - ); - }, + child: ReorderableListView.builder( + scrollDirection: Axis.horizontal, + itemCount: tabs.length, + padding: const EdgeInsets.symmetric(horizontal: 12), + onReorder: (oldIndex, newIndex) { + HapticFeedback.lightImpact(); + chatTabsStore.reorderTab(oldIndex, newIndex); + }, + proxyDecorator: (child, index, animation) { + return Material( + color: Colors.transparent, + child: child, + ); + }, + itemBuilder: (context, index) { + final tabInfo = chatTabsStore.tabs[index]; + return Padding( + key: ValueKey(tabInfo.channelId), + padding: EdgeInsets.only( + right: index < tabs.length - 1 ? 4 : 0, ), - ), - if (showMerge) - Positioned( - right: 12, - top: 0, - bottom: 0, - child: Center( - child: _buildMoreActionsMenu(context), - ), - ), - ], + child: _buildTab(context, index), + ); + }, ), ), ), @@ -214,329 +154,72 @@ class ChatTabs extends StatelessWidget { ); } - Widget _buildMoreActionsMenu(BuildContext context) { - final settings = chatTabsStore.activeChatStore.settings; - - return _AnchoredPopupMenu( - itemsBuilder: (close) => [ - Observer( - builder: (_) => CheckboxMenuButton( - value: chatTabsStore.mergedMode, - onChanged: (_) { - HapticFeedback.selectionClick(); - chatTabsStore.toggleMergedMode(); - }, - child: const Text('Merge chats'), + Future _confirmRemoveTab( + BuildContext context, + int index, + String displayName, + ) async { + final confirmed = await showDialog( + context: context, + builder: (context) => FrostyDialog( + title: 'Remove $displayName', + message: "Are you sure you want to remove $displayName's chat tab?", + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), ), - ), - Observer( - builder: (_) => CheckboxMenuButton( - value: settings.focusCurrentChannel, - onChanged: (newValue) { - HapticFeedback.selectionClick(); - settings.focusCurrentChannel = newValue ?? false; - }, - child: const Text('Focus current channel'), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Remove'), ), - ), - ], - anchorBuilder: (context, toggle) { - return IconButton.filledTonal( - icon: const Icon(Icons.more_vert, size: 18), - tooltip: 'Chat options', - visualDensity: VisualDensity.compact, - style: IconButton.styleFrom( - minimumSize: const Size(42, 42), - backgroundColor: - Theme.of(context).colorScheme.surfaceContainerHighest, - ), - onPressed: () { - HapticFeedback.selectionClick(); - toggle(); - }, - ); - }, + ], + ), ); + + if (confirmed == true) { + HapticFeedback.lightImpact(); + chatTabsStore.removeTab(index); + } } Widget _buildTab(BuildContext context, int index) { final tabInfo = chatTabsStore.tabs[index]; final isActive = index == chatTabsStore.activeTabIndex; - final isSecondary = !tabInfo.isPrimary; + final isActivated = tabInfo.isActivated; final displayName = getReadableName( tabInfo.displayName, tabInfo.channelLogin, ); - return Observer( - builder: (context) { - final theme = Theme.of(context); - final isActivated = tabInfo.isActivated; - final hasUnread = chatTabsStore.hasUnreadMessages(index); - final dimmed = !isActivated || !(tabInfo.chatStore?.isConnected ?? false); + final avatar = ProfilePicture(userLogin: tabInfo.channelLogin, radius: 12); - InputChip buildChip({VoidCallback? onDeleted}) => InputChip( - avatar: Badge( - smallSize: 8, - backgroundColor: theme.colorScheme.primary, - isLabelVisible: hasUnread, - child: Opacity( - opacity: dimmed ? 0.5 : 1.0, - child: ProfilePicture( - userLogin: tabInfo.channelLogin, - radius: 12, - ), - ), + return InputChip( + avatar: isActivated ? avatar : Opacity(opacity: 0.5, child: avatar), + label: Text( + displayName, + style: isActivated + ? null + : TextStyle( + color: Theme.of( + context, + ).textTheme.bodyMedium?.color?.withValues(alpha: 0.5), ), - label: Text( - displayName, - style: dimmed - ? TextStyle( - color: theme.textTheme.bodyMedium?.color - ?.withValues(alpha: 0.5), - ) - : null, - ), - selected: isActive, - showCheckmark: false, - visualDensity: VisualDensity.compact, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - onPressed: () { - if (isActive) return; - HapticFeedback.selectionClick(); - chatTabsStore.setActiveTab(index); - }, - onDeleted: onDeleted, - deleteButtonTooltipMessage: - onDeleted != null ? 'Tab options' : null, - ); - - if (!isSecondary) return buildChip(); - - return _AnchoredPopupMenu( - itemsBuilder: (close) => [ - if (isActivated) - MenuItemButton( - leadingIcon: const Icon(Icons.power_off_rounded, size: 18), - child: const Text('Disconnect'), - onPressed: () { - HapticFeedback.lightImpact(); - close(); - chatTabsStore.deactivateTab(index); - }, - ), - MenuItemButton( - leadingIcon: Icon( - Icons.delete_outline_rounded, - size: 18, - color: theme.colorScheme.error, - ), - child: Text( - 'Remove', - style: TextStyle(color: theme.colorScheme.error), - ), - onPressed: () { - HapticFeedback.lightImpact(); - close(); - chatTabsStore.removeTab(index); - }, - ), - ], - anchorBuilder: (context, toggle) => - buildChip(onDeleted: toggle), - ); - }, - ); - } - - /// Builds a bubble for a Twitch native shared chat participant channel. - /// Unlike manual tabs, these don't own a connection: tapping spotlights - /// the channel (unfades it, fading the rest — see [chat_message.dart]), - /// and the trailing menu hides/shows the channel's messages instead of - /// disconnecting/removing a tab. - Widget _buildSharedChatBubble( - BuildContext context, - String channelId, - UserTwitch user, - ) { - final activeStore = chatTabsStore.activeChatStore; - final displayName = getReadableName(user.displayName, user.login); - - return Observer( - builder: (context) { - final theme = Theme.of(context); - final isFocused = activeStore.sharedChatBubbles.isFocused(channelId); - final isHidden = activeStore.sharedChatBubbles.isHidden(channelId); - - InputChip buildChip({VoidCallback? onDeleted}) => InputChip( - avatar: Opacity( - opacity: isHidden ? 0.5 : 1.0, - child: ProfilePicture(userLogin: user.login, radius: 12), - ), - label: Text( - displayName, - style: isHidden - ? TextStyle( - color: theme.textTheme.bodyMedium?.color - ?.withValues(alpha: 0.5), - ) - : null, - ), - selected: isFocused, - showCheckmark: false, - visualDensity: VisualDensity.compact, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - onPressed: () { - HapticFeedback.selectionClick(); - activeStore.sharedChatBubbles.toggleFocus(channelId); - }, - onDeleted: onDeleted, - deleteButtonTooltipMessage: - onDeleted != null ? 'Channel options' : null, - ); - - return _AnchoredPopupMenu( - itemsBuilder: (close) => [ - MenuItemButton( - leadingIcon: Icon( - isHidden - ? Icons.visibility_rounded - : Icons.visibility_off_rounded, - size: 18, - ), - child: Text(isHidden ? 'Show channel' : 'Hide channel'), - onPressed: () { - HapticFeedback.lightImpact(); - close(); - if (isHidden) { - activeStore.sharedChatBubbles.show(channelId); - } else { - activeStore.sharedChatBubbles.hide(channelId); - } - }, - ), - ], - anchorBuilder: (context, toggle) => buildChip(onDeleted: toggle), - ); - }, - ); - } -} - -/// Lightweight popup menu anchored to a child widget. The menu's top-right -/// aligns to the anchor's bottom-right via [CompositedTransformFollower] and -/// fades + scales in from that corner. [MenuAnchor] doesn't expose an entry -/// animation hook in this Flutter version, so we drive one ourselves. -class _AnchoredPopupMenu extends StatefulWidget { - final Widget Function(BuildContext context, VoidCallback toggle) - anchorBuilder; - final List 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), ), + selected: isActive, + showCheckmark: false, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onPressed: () { + if (!isActive) { + HapticFeedback.selectionClick(); + chatTabsStore.setActiveTab(index); + } + }, + onDeleted: tabInfo.isPrimary + ? null + : () => _confirmRemoveTab(context, index, displayName), + deleteButtonTooltipMessage: 'Close chat', ); } } diff --git a/lib/screens/channel/chat/widgets/chat_user_modal.dart b/lib/screens/channel/chat/widgets/chat_user_modal.dart index 6cb2e55a..2e0700ae 100644 --- a/lib/screens/channel/chat/widgets/chat_user_modal.dart +++ b/lib/screens/channel/chat/widgets/chat_user_modal.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_message.dart'; -import 'package:frosty/screens/channel/chat/widgets/moderation_actions.dart'; import 'package:frosty/utils.dart'; import 'package:frosty/utils/modal_bottom_sheet.dart'; import 'package:frosty/widgets/alert_message.dart'; @@ -15,7 +14,6 @@ class ChatUserModal extends StatefulWidget { final String username; final String displayName; final String userId; - final VoidCallback? onActivateSourceTab; const ChatUserModal({ super.key, @@ -23,7 +21,6 @@ class ChatUserModal extends StatefulWidget { required this.username, required this.displayName, required this.userId, - this.onActivateSourceTab, }); @override @@ -64,7 +61,6 @@ class _ChatUserModalState extends State { IconButton( tooltip: 'Reply', onPressed: () { - widget.onActivateSourceTab?.call(); widget.chatStore.textController.text = '@${widget.username} '; Navigator.pop(context); @@ -81,13 +77,6 @@ class _ChatUserModalState extends State { name: name, userLogin: widget.username, userId: widget.userId, - // Moderator actions for this user (no messageId → no - // Delete); renders nothing for non-moderators. - moderationActions: ModerationActions( - chatStore: widget.chatStore, - targetUserId: widget.userId, - targetName: name, - ), ), ), icon: Icon(Icons.adaptive.more_rounded), @@ -126,7 +115,6 @@ class _ChatUserModalState extends State { 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 deleted file mode 100644 index 390100c6..00000000 --- a/lib/screens/channel/chat/widgets/moderation_actions.dart +++ /dev/null @@ -1,212 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:frosty/apis/base_api_client.dart'; -import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; -import 'package:frosty/screens/onboarding/login_webview.dart'; -import 'package:frosty/screens/settings/stores/auth_store.dart'; - -/// Moderator actions (delete / timeout / ban, plus unban) for [targetUserId], -/// reused by the message long-press menu and the user panel. -/// -/// Renders nothing unless the viewer can moderate the channel, or should see -/// an opt-in row when IRC/broadcaster hints mod capability without OAuth scopes. -/// [messageId] enables the per-message Delete action; omit it (e.g. the user -/// panel, which isn't tied to one message) to hide Delete. The unban action -/// only appears when the user is currently banned/timed-out (seen via CLEARCHAT). -class ModerationActions extends StatelessWidget { - final ChatStore chatStore; - final String targetUserId; - - /// Display name used in confirmation notifications. - final String targetName; - - /// The message to delete; null hides the Delete action. - final String? messageId; - - const ModerationActions({ - super.key, - required this.chatStore, - required this.targetUserId, - required this.targetName, - this.messageId, - }); - - @override - Widget build(BuildContext context) { - return Observer( - builder: (context) { - final auth = chatStore.auth; - final hasModScopes = auth.hasModeratorScopes; - - if (hasModScopes) { - if (auth.user.canPerformModeratorActions(chatStore.channelId)) { - return _actionsColumn(context); - } - return const SizedBox.shrink(); - } - - // IRC/broadcaster is a CTA hint only — not authority to call Helix. - // Read userState.mod only on the no-scopes path so USERSTATE acks do - // not rebuild this sheet while real actions are already available. - final isBroadcaster = auth.user.details?.id == chatStore.channelId; - final ircModHint = chatStore.userState.mod; - if (isBroadcaster || ircModHint) { - return _enableModeratorToolsRow(context, auth); - } - - return const SizedBox.shrink(); - }, - ); - } - - Widget _enableModeratorToolsRow(BuildContext context, AuthStore auth) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Divider(), - ListTile( - onTap: () { - Navigator.pop(context); - // Use the root navigator after closing the sheet. - openEnableModeratorTools(auth); - }, - leading: const Icon(Icons.shield_outlined), - title: const Text('Enable moderator tools…'), - subtitle: const Text( - 'Authorize Twitch permissions to delete, timeout, and ban.', - ), - ), - ], - ); - } - - Widget _actionsColumn(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Divider(), - // Compact one-row punitive actions, ordered least- to most-destructive. - Padding( - padding: const EdgeInsets.fromLTRB(12, 4, 12, 8), - child: Row( - children: [ - if (messageId != null) ...[ - Expanded( - child: _button( - Icons.delete_outline, - 'Delete', - () => _run(context, _delete), - ), - ), - const SizedBox(width: 8), - ], - Expanded( - child: _button( - Icons.timer_outlined, - 'Timeout 10min', - () => _run(context, _timeout), - ), - ), - const SizedBox(width: 8), - Expanded( - child: _button( - Icons.block, - 'Ban', - () => _run(context, _ban), - ), - ), - ], - ), - ), - // Recovery action, separate from the punitive row to avoid mistaps and - // only shown when the user is actually banned/timed-out. - if (chatStore.isUserBannedOrTimedOut(targetUserId)) - ListTile( - onTap: () => _run(context, _unban), - leading: const Icon(Icons.lock_open_rounded), - title: const Text('Remove ban or timeout'), - ), - ], - ); - } - - /// Compact icon-over-label button. - Widget _button(IconData icon, String label, VoidCallback onTap) { - return OutlinedButton( - onPressed: onTap, - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 10), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 20), - const SizedBox(height: 4), - FittedBox( - fit: BoxFit.scaleDown, - child: Text(label, style: const TextStyle(fontSize: 12)), - ), - ], - ), - ); - } - - /// Closes the sheet, then runs [action], surfacing Twitch's reason on failure. - Future _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 index 7dc83394..734408b3 100644 --- a/lib/screens/channel/chat/widgets/reply_thread.dart +++ b/lib/screens/channel/chat/widgets/reply_thread.dart @@ -61,7 +61,7 @@ class ReplyThread extends StatelessWidget { padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), child: Text( '$replyName: $replyBody', - style: const TextStyle(fontWeight: FontWeight.w600), + style: const TextStyle(fontWeight: FontWeight.w500), ), ), Flexible( diff --git a/lib/screens/channel/video/channel_info_header.dart b/lib/screens/channel/video/channel_info_header.dart deleted file mode 100644 index 0cf77aa2..00000000 --- a/lib/screens/channel/video/channel_info_header.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:frosty/screens/channel/video/stream_info_bar.dart'; -import 'package:frosty/screens/channel/video/video_player_interface.dart'; -import 'package:frosty/screens/settings/stores/auth_store.dart'; -import 'package:frosty/widgets/follow_button.dart'; - -/// Channel metadata + follow control shown under the player (portrait). -class ChannelInfoHeader extends StatelessWidget { - final VideoPlayerInterface videoStore; - final AuthStore authStore; - final String userId; - final String displayName; - final bool isInSharedChatMode; - - const ChannelInfoHeader({ - super.key, - required this.videoStore, - required this.authStore, - required this.userId, - required this.displayName, - this.isInSharedChatMode = false, - }); - - @override - Widget build(BuildContext context) { - return Material( - color: Theme.of(context).scaffoldBackgroundColor, - child: Padding( - padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), - child: Observer( - builder: (context) { - final streamInfo = videoStore.streamInfo; - final offline = streamInfo == null; - - return Row( - spacing: 8, - children: [ - Expanded( - child: StreamInfoBar( - streamInfo: streamInfo, - offlineChannelInfo: videoStore.offlineChannelInfo, - displayName: displayName, - isCompact: true, - isOffline: offline, - isInSharedChatMode: isInSharedChatMode, - showTextShadows: false, - tooltipTriggerMode: TooltipTriggerMode.longPress, - ), - ), - FollowButton( - authStore: authStore, - userId: userId, - displayName: displayName, - ), - ], - ); - }, - ), - ), - ); - } -} diff --git a/lib/screens/channel/video/chat_latency_sync.dart b/lib/screens/channel/video/chat_latency_sync.dart deleted file mode 100644 index 0b5ff544..00000000 --- a/lib/screens/channel/video/chat_latency_sync.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:frosty/screens/channel/video/video_timing_constants.dart'; -import 'package:frosty/screens/settings/stores/settings_store.dart'; - -/// Single owner of `SettingsStore.syncedChatDelay` writes from the video -/// player stores. Previously both stores poked the setting directly, -/// making settings an ad-hoc event bus between video and chat. -class ChatLatencySync { - ChatLatencySync({required this.settingsStore}); - - final SettingsStore settingsStore; - - /// Reset the delay to zero on stream transitions (channel enter, stream - /// end, chat-only toggle, etc). No-op when auto-sync is disabled. - void reset() { - if (settingsStore.autoSyncChatDelay) { - settingsStore.syncedChatDelay = 0.0; - } - } - - /// Push a fresh video-latency measurement. Only writes when drift from - /// the current value exceeds the tolerance, to avoid thrashing the chat - /// delay countdown on minor fluctuations. - void report(double latencySeconds) { - if (!settingsStore.autoSyncChatDelay) return; - final current = settingsStore.syncedChatDelay; - if (current == 0 || - (latencySeconds - current).abs() > - VideoTimingConstants.chatSyncDriftToleranceSeconds) { - settingsStore.syncedChatDelay = latencySeconds; - } - } -} diff --git a/lib/screens/channel/video/native_video.dart b/lib/screens/channel/video/native_video.dart deleted file mode 100644 index 78c51f52..00000000 --- a/lib/screens/channel/video/native_video.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'package:better_native_video_player/better_native_video_player.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:frosty/screens/channel/video/native_video_store.dart'; - -/// Native video player widget backed by AVPlayerViewController (iOS) -/// and ExoPlayer (Android). -class NativeVideo extends StatelessWidget { - final NativeVideoStore nativeVideoStore; - - const NativeVideo({super.key, required this.nativeVideoStore}); - - @override - Widget build(BuildContext context) { - return Observer( - builder: (context) { - final controller = nativeVideoStore.controller; - final error = nativeVideoStore.error; - - final isOffline = - !nativeVideoStore.loading && - nativeVideoStore.streamInfo == null && - error == null; - - return ColoredBox( - color: Colors.black, - child: Stack( - children: [ - if (controller != null && !isOffline) - NativeVideoPlayer( - key: ObjectKey(controller), - controller: controller, - ), - if (controller != null && !nativeVideoStore.hasPlayedOnce && !isOffline) - const ColoredBox( - color: Colors.black, - child: SizedBox.expand(), - ), - if (error != null) - Align( - alignment: const Alignment(0, 0.3), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.error_outline_rounded, - color: Colors.amber.withValues(alpha: 0.7), - size: 32, - ), - const SizedBox(height: 10), - Text( - error, - style: const TextStyle( - color: Colors.white60, - fontSize: 13, - height: 1.4, - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - ), - if (isOffline) - const Center( - child: Icon( - Icons.tv_off_rounded, - color: Colors.white24, - size: 32, - ), - ), - if (nativeVideoStore.isAudioOnlyMode && !isOffline) - const ColoredBox( - color: Colors.black, - child: SizedBox.expand( - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.headphones_rounded, - color: Colors.white24, - size: 32, - ), - SizedBox(height: 8), - Text( - 'Audio Only', - style: TextStyle( - color: Colors.white24, - fontSize: 13, - ), - ), - ], - ), - ), - ), - ), - ], - ), - ); - }, - ); - } -} diff --git a/lib/screens/channel/video/native_video_player_interface.dart b/lib/screens/channel/video/native_video_player_interface.dart deleted file mode 100644 index a01e34ec..00000000 --- a/lib/screens/channel/video/native_video_player_interface.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:frosty/screens/channel/video/video_player_interface.dart'; - -/// Capabilities that only the native player can meaningfully support. -/// Callers that need these must type-check against this interface. -abstract class NativeVideoPlayerInterface extends VideoPlayerInterface { - /// Whether the current quality is an HLS audio-only variant. - bool get isAudioOnlyMode; -} diff --git a/lib/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart deleted file mode 100644 index 31077620..00000000 --- a/lib/screens/channel/video/native_video_store.dart +++ /dev/null @@ -1,1469 +0,0 @@ -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 deleted file mode 100644 index afce5344..00000000 --- a/lib/screens/channel/video/native_video_store.g.dart +++ /dev/null @@ -1,517 +0,0 @@ -// 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 deleted file mode 100644 index 72b83815..00000000 --- a/lib/screens/channel/video/playback_error_recovery.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:frosty/screens/channel/video/video_timing_constants.dart'; - -/// Whether [NativeVideoStore] should auto-refresh after a mid-stream player -/// error instead of pinning the Reload overlay. -/// -/// ExoPlayer's "Source error" (screen-off / network switch) arrives without a -/// status code, so callers must not rely on 4xx alone. [mediaServicesWereReset] -/// has its own dispose+rebuild path and is excluded here. -bool shouldAutoRecoverFromPlayerError({ - required bool refreshCapReached, - String? errorCode, -}) { - if (refreshCapReached) return false; - if (errorCode == 'mediaServicesWereReset') return false; - return true; -} - -/// Delay before an automatic refresh for [statusCode]. -/// -/// Token/CDN 4xx can refresh immediately; network/source failures get a short -/// pause so the interface can come back after a switch or screen wake. -Duration playerErrorRetryDelay({int? statusCode}) { - if (statusCode != null && statusCode >= 400 && statusCode < 500) { - return Duration.zero; - } - return VideoTimingConstants.initRetryDelay; -} diff --git a/lib/screens/channel/video/stream_info_bar.dart b/lib/screens/channel/video/stream_info_bar.dart index 580acfd0..8673eb3a 100644 --- a/lib/screens/channel/video/stream_info_bar.dart +++ b/lib/screens/channel/video/stream_info_bar.dart @@ -202,7 +202,6 @@ class StreamInfoBar extends StatelessWidget { Navigator.push( context, MaterialPageRoute( - settings: const RouteSettings(name: CategoryStreams.routeName), builder: (context) => CategoryStreams( categoryId: @@ -294,7 +293,6 @@ class StreamInfoBar extends StatelessWidget { onDoubleTap: () => Navigator.push( context, MaterialPageRoute( - settings: const RouteSettings(name: CategoryStreams.routeName), builder: (context) => CategoryStreams( categoryId: streamInfo?.gameId ?? '', diff --git a/lib/screens/channel/video/stream_info_poller.dart b/lib/screens/channel/video/stream_info_poller.dart deleted file mode 100644 index 5917ffed..00000000 --- a/lib/screens/channel/video/stream_info_poller.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:frosty/apis/twitch_api.dart'; -import 'package:frosty/models/channel.dart'; -import 'package:frosty/models/stream.dart'; -import 'package:frosty/screens/channel/video/video_timing_constants.dart'; - -/// Snapshot of a channel's live state. -class StreamInfoResult { - const StreamInfoResult({this.stream, this.offlineChannel}); - - /// Non-null when the channel is currently live. - final StreamTwitch? stream; - - /// Non-null when the channel is confirmed offline (channel metadata fetched - /// successfully but no live stream). Both fields null means the fetch - /// failed entirely (network error, etc.). - final Channel? offlineChannel; -} - -/// Debounced, deduplicated Twitch stream-info fetcher shared by both video -/// stores. Each store still owns its observable `_streamInfo` / -/// `_offlineChannelInfo` fields and decides how to react to the result. -class StreamInfoPoller { - StreamInfoPoller({ - required this.twitchApi, - required this.userLogin, - required this.userId, - }); - - final TwitchApi twitchApi; - final String userLogin; - final String userId; - - DateTime? _lastUpdate; - Future? _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_overlay.dart b/lib/screens/channel/video/video_overlay.dart index f3589112..4b50dcbe 100644 --- a/lib/screens/channel/video/video_overlay.dart +++ b/lib/screens/channel/video/video_overlay.dart @@ -3,12 +3,10 @@ import 'dart:io'; 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/screens/channel/chat/details/chat_users_list.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.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_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.dart'; @@ -19,11 +17,12 @@ import 'package:frosty/widgets/section_header.dart'; import 'package:frosty/widgets/uptime.dart'; import 'package:intl/intl.dart'; import 'package:native_device_orientation/native_device_orientation.dart'; +import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; /// Creates a widget containing controls which enable interactions with an underlying [Video] widget. class VideoOverlay extends StatelessWidget { - final VideoPlayerInterface videoStore; + final VideoStore videoStore; final ChatStore chatStore; final SettingsStore settingsStore; @@ -52,7 +51,11 @@ class VideoOverlay extends StatelessWidget { @override Widget build(BuildContext context) { - final surfaceColor = context.frostyColors.overlayOnSurface; + final surfaceColor = context + .watch() + .dark + .colorScheme + .onSurface; final backButton = IconButton( tooltip: 'Back', @@ -90,7 +93,7 @@ class VideoOverlay extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SectionHeader( - 'Quality', + 'Stream quality', padding: EdgeInsets.fromLTRB(16, 0, 16, 8), isFirst: true, ), @@ -99,35 +102,26 @@ class VideoOverlay extends StatelessWidget { builder: (context) => ListView( shrinkWrap: true, primary: false, - children: [ - ...videoStore.availableStreamQualities.map( - (quality) => ListTile( - trailing: videoStore.streamQuality == quality - ? const Icon(Icons.check_rounded) - : null, - title: Text(quality), - onTap: () { - videoStore.setStreamQuality(quality); - SharedPreferences.getInstance().then( - (prefs) => prefs.setString( - lastStreamQualityKey(videoStore.userLogin), - quality, - ), - ); - Navigator.pop(context); - }, - ), - ), - // Qualities the broadcaster offers only to subscribers - // (from the playback token's restricted_bitrates). - ...videoStore.restrictedStreamQualities.map( - (quality) => ListTile( - enabled: false, - title: Text(quality), - subtitle: const Text('Subscribers only'), - ), - ), - ], + children: videoStore.availableStreamQualities + .map( + (quality) => ListTile( + trailing: videoStore.streamQuality == quality + ? const Icon(Icons.check_rounded) + : null, + title: Text(quality), + onTap: () { + videoStore.setStreamQuality(quality); + SharedPreferences.getInstance().then( + (prefs) => prefs.setString( + 'last_stream_quality', + quality, + ), + ); + Navigator.pop(context); + }, + ), + ) + .toList(), ), ), ), @@ -150,23 +144,21 @@ class VideoOverlay extends StatelessWidget { ), ); - final fullScreenButton = Observer( - builder: (_) => Tooltip( - message: videoStore.settingsStore.fullScreen - ? 'Exit fullscreen mode' - : 'Enter fullscreen mode', - preferBelow: false, - child: IconButton( - icon: Icon( - videoStore.settingsStore.fullScreen - ? Icons.fullscreen_exit_rounded - : Icons.fullscreen_rounded, - color: surfaceColor, - shadows: kOverlayShadow, - ), - onPressed: () => videoStore.settingsStore.fullScreen = - !videoStore.settingsStore.fullScreen, + final fullScreenButton = Tooltip( + message: videoStore.settingsStore.fullScreen + ? 'Exit fullscreen mode' + : 'Enter fullscreen mode', + preferBelow: false, + child: IconButton( + icon: Icon( + videoStore.settingsStore.fullScreen + ? Icons.fullscreen_exit_rounded + : Icons.fullscreen_rounded, + color: surfaceColor, + shadows: kOverlayShadow, ), + onPressed: () => videoStore.settingsStore.fullScreen = + !videoStore.settingsStore.fullScreen, ), ); @@ -185,16 +177,16 @@ class VideoOverlay extends StatelessWidget { if (context.isPortrait) { // Detect physical device tilt to rotate to optimal orientation final physicalOrientation = - await NativeDeviceOrientationCommunicator().orientation( - useSensor: true, - ); + await NativeDeviceOrientationCommunicator() + .orientation(useSensor: true); // Map native orientation to Flutter's DeviceOrientation // iOS: native landscapeLeft = notch left, needs swap to Flutter's landscapeRight // Android: direct mapping works correctly final needsSwap = Platform.isIOS; - if (physicalOrientation == NativeDeviceOrientation.landscapeLeft) { + if (physicalOrientation == + NativeDeviceOrientation.landscapeLeft) { SystemChrome.setPreferredOrientations([ needsSwap ? DeviceOrientation.landscapeRight @@ -270,6 +262,7 @@ class VideoOverlay extends StatelessWidget { displayName: chatStore.displayName, showUptime: false, showViewerCount: false, + showOfflineIndicator: false, textColor: surfaceColor, isOffline: true, isInSharedChatMode: @@ -322,25 +315,6 @@ class VideoOverlay extends StatelessWidget { height: 80, // Covers bottom area around controls child: Container(decoration: bottomGradient), ), - if (videoStore.isBlockingAds) - Positioned( - top: 8, - left: 8, - child: IgnorePointer( - child: DecoratedBox( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.8), - ), - child: const Padding( - padding: EdgeInsets.symmetric(horizontal: 8, vertical: 5), - child: Text( - 'Blocking ads', - style: TextStyle(color: Colors.white, fontSize: 12), - ), - ), - ), - ), - ), // Content Stack( children: [ @@ -373,36 +347,32 @@ class VideoOverlay extends StatelessWidget { if (videoStore.settingsStore.fullScreen && context.isLandscape) chatOverlayButton, - if (!Platform.isIOS || - isIPad() || - videoStore is NativeVideoStore) - videoSettingsButton, + if (!Platform.isIOS || isIPad()) videoSettingsButton, ], ), - if (!videoStore.loading) - Center( - child: Tooltip( - message: videoStore.paused ? 'Play' : 'Pause', - preferBelow: false, - child: IconButton( - iconSize: 56, - icon: Icon( - videoStore.paused - ? Icons.play_arrow_rounded - : Icons.pause_rounded, - color: surfaceColor, - shadows: [ - Shadow( - offset: const Offset(0, 3), - blurRadius: 8, - color: Colors.black.withValues(alpha: 0.6), - ), - ], - ), - onPressed: videoStore.handlePausePlay, + Center( + child: Tooltip( + message: videoStore.paused ? 'Play' : 'Pause', + preferBelow: false, + child: IconButton( + iconSize: 56, + icon: Icon( + videoStore.paused + ? Icons.play_arrow_rounded + : Icons.pause_rounded, + color: surfaceColor, + shadows: [ + Shadow( + offset: const Offset(0, 3), + blurRadius: 8, + color: Colors.black.withValues(alpha: 0.6), + ), + ], ), + onPressed: videoStore.handlePausePlay, ), ), + ), Align( alignment: Alignment.bottomLeft, child: Row( diff --git a/lib/screens/channel/video/video_player_interface.dart b/lib/screens/channel/video/video_player_interface.dart deleted file mode 100644 index e8dd75d0..00000000 --- a/lib/screens/channel/video/video_player_interface.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:frosty/models/channel.dart'; -import 'package:frosty/models/stream.dart'; -import 'package:frosty/screens/settings/stores/settings_store.dart'; - -/// Abstraction over the video player store so both [VideoStore] (WebView) -/// and [NativeVideoStore] can drive the same overlay and channel UI. -/// -/// Native-only capabilities live on [NativeVideoPlayerInterface]. -abstract class VideoPlayerInterface { - SettingsStore get settingsStore; - - /// Channel this store is driving — used as a key for per-channel - /// persistence (e.g. last-selected quality). - String get userLogin; - - bool get loading; - bool get paused; - bool get overlayVisible; - bool get isInPipMode; - StreamTwitch? get streamInfo; - Channel? get offlineChannelInfo; - List get availableStreamQualities; - - /// Qualities that exist for this stream but require a subscription. - /// Only the native player can know this (parsed from the playback token); - /// the WebView player always reports none. - List get restrictedStreamQualities; - - String get streamQuality; - String? get latency; - - /// True while TwitchAdSolutions is actively avoiding an ad break. - bool get isBlockingAds; - - void handleVideoTap(); - void handlePausePlay(); - void handleToggleOverlay(); - Future handleRefresh(); - void requestPictureInPicture(); - void togglePictureInPicture(); - Future updateStreamQualities(); - Future setStreamQuality(String quality); - Future updateStreamInfo({bool forceUpdate}); - void handleAppResume(); - - /// Called from the platform PiP callbacks on Android so the store can sync - /// its overlay state, and its playback, with the OS PiP window. - void handleAndroidPipChanged(bool isInPip); - - void dispose(); -} diff --git a/lib/screens/channel/video/video_store.dart b/lib/screens/channel/video/video_store.dart index 971a25bf..ca9579a1 100644 --- a/lib/screens/channel/video/video_store.dart +++ b/lib/screens/channel/video/video_store.dart @@ -5,13 +5,8 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:frosty/apis/twitch_api.dart'; -import 'package:frosty/constants.dart'; import 'package:frosty/models/channel.dart'; import 'package:frosty/models/stream.dart'; -import 'package:frosty/screens/channel/video/chat_latency_sync.dart'; -import 'package:frosty/screens/channel/video/stream_info_poller.dart'; -import 'package:frosty/screens/channel/video/video_player_interface.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'; @@ -25,11 +20,10 @@ part 'video_store.g.dart'; class VideoStore = VideoStoreBase with _$VideoStore; -abstract class VideoStoreBase with Store implements VideoPlayerInterface { +abstract class VideoStoreBase with Store { final TwitchApi twitchApi; /// The userlogin of the current channel. - @override final String userLogin; /// The user ID of the current channel. @@ -37,41 +31,12 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { final AuthStore authStore; - @override final SettingsStore settingsStore; /// The [SimplePip] instance used for initiating PiP on Android. final pip = SimplePip(); - late final ChatLatencySync _chatLatencySync = - ChatLatencySync(settingsStore: settingsStore); - late final StreamInfoPoller _streamInfoPoller = StreamInfoPoller( - twitchApi: twitchApi, - userLogin: userLogin, - userId: userId, - ); var _firstTimeSettingQuality = true; - var _highLatencyCount = 0; - - /// Whether [initVideo] should run on the next [onPageFinished]. - /// - /// Dart-controlled rather than JS-side (`window._injected`) to avoid race - /// conditions when `loadRequest` fires before the previous page fully - /// loads — but re-armed by `onPageStarted` on *every* navigation so it - /// keeps the page-scoped lifetime the JS flag had for free. This controller - /// outlives the [Video] widget, which reloads [videoUrl] on each mount, and - /// a flag only [handleRefresh] reset left those reloads permanently - /// uninitialized: no play/pause listeners, no `_videoEl`, no autoplay, no - /// overlay CSS, and no recovery short of restarting the app. - var _needsInit = true; - - /// Whether the *user* paused, as opposed to the page pausing itself. - /// - /// Backgrounding can suspend playback with no way for the page to resume on - /// its own — the native store recovers via `play()` on resume, the webview - /// store had no equivalent. This distinguishes "resume it for them" from - /// "they meant it". - var _userPaused = false; /// The video web view params used for enabling auto play. late final PlatformWebViewControllerCreationParams _videoWebViewParams; @@ -87,6 +52,8 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { final receivedLatency = message.message; _latency = receivedLatency; + if (!settingsStore.autoSyncChatDelay) return; + // Parse latency from abbreviated format: "5s" -> 5.0 final numericPart = receivedLatency.replaceAll( RegExp(r'[^0-9.]'), @@ -94,43 +61,8 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { ); final latencyAsDouble = double.tryParse(numericPart); - // Auto-recover when latency climbs too high. - // Intentionally skips the chat delay sync — the stale value - // would desync chat further. Recovery will restore it. - if (latencyAsDouble != null && - latencyAsDouble >= VideoTimingConstants.highLatencyThresholdSeconds && - !_paused && - !_loading) { - _highLatencyCount++; - if (_highLatencyCount == 1) { - debugPrint( - 'VideoStore: high latency (${latencyAsDouble.round()}s), seeking to live edge', - ); - try { - videoWebViewController.runJavaScript(''' - (function() { - const v = window._videoEl || document.querySelector("video"); - if (v && v.buffered.length > 0) { - v.currentTime = v.buffered.end(v.buffered.length - 1); - } - })(); - '''); - } catch (e) { - debugPrint(e.toString()); - } - } else { - debugPrint( - 'VideoStore: latency still high (${latencyAsDouble.round()}s), refreshing', - ); - _highLatencyCount = 0; - handleRefresh(); - } - return; - } - _highLatencyCount = 0; - if (latencyAsDouble != null) { - _chatLatencySync.report(latencyAsDouble); + settingsStore.chatDelay = latencyAsDouble; } }, ) @@ -148,8 +80,7 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { return; } final prefs = await SharedPreferences.getInstance(); - final lastStreamQuality = - prefs.getString(lastStreamQualityKey(userLogin)); + final lastStreamQuality = prefs.getString('last_stream_quality'); if (lastStreamQuality == null) return; setStreamQuality(lastStreamQuality); } @@ -165,40 +96,48 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { ..addJavaScriptChannel( 'VideoPlaying', onMessageReceived: (message) { - _loading = false; _paused = false; if (Platform.isAndroid) pip.setIsPlaying(true); }, ) ..addJavaScriptChannel( 'PipEntered', - onMessageReceived: (message) => _setPipActive(true), + onMessageReceived: (message) { + _overlayWasVisibleBeforePip = _overlayVisible; + _isInPipMode = true; + _overlayTimer?.cancel(); + _overlayVisible = true; + }, ) ..addJavaScriptChannel( 'PipExited', - onMessageReceived: (message) => _setPipActive(false), + onMessageReceived: (message) { + _isInPipMode = false; + if (_overlayWasVisibleBeforePip) { + _updateLatencyTrackerVisibility(true); + _scheduleOverlayHide(); + } else { + _overlayVisible = false; + _updateLatencyTrackerVisibility(false); + } + }, ) ..setNavigationDelegate( NavigationDelegate( - // Every real navigation invalidates the injected JS context, so - // every one must re-arm init. Unconditional is safe: the - // onPageFinished handler below still filters on videoUrl. - onPageStarted: (url) { - _needsInit = true; - // Best-effort: inject vaft as early as the WebView allows. - // True document-start injection isn't exposed by webview_flutter - // on Android; native player remains the reliable adblock path. - if (settingsStore.blockTwitchAds) { - unawaited(_injectVaftUserScript()); - } - }, onPageFinished: (url) async { if (url != videoUrl) return; - if (settingsStore.blockTwitchAds) { - await _injectVaftUserScript(); - } - if (!_needsInit) return; - _needsInit = false; + // Safe evaluation of JavaScript boolean result + final result = await videoWebViewController + .runJavaScriptReturningResult( + 'window._injected ? true : false', + ); + final injected = result is bool + ? result + : (result.toString().toLowerCase() == 'true'); + if (injected) return; + await videoWebViewController.runJavaScript( + 'window._injected = true;', + ); await initVideo(); _acceptContentWarning(); }, @@ -217,6 +156,9 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { /// Timer for periodic JavaScript state cleanup to prevent memory accumulation. Timer? _jsCleanupTimer; + /// Tracks the last time stream info was updated to prevent double refresh + DateTime? _lastStreamInfoUpdate; + /// Disposes the overlay reactions. late final ReactionDisposer _disposeOverlayReaction; @@ -228,9 +170,6 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { /// Disposes the latency settings reaction. ReactionDisposer? _disposeLatencySettingsReaction; - @readonly - var _loading = true; - /// If the video is currently paused. /// /// Does not pause or play the video, only used for rendering state of the overlay. @@ -252,17 +191,11 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { @readonly List _availableStreamQualities = []; - // The WebView player has no access to the playback token, so sub-only - // qualities can't be detected here. - @override - List get restrictedStreamQualities => const []; - // The current stream quality index @readonly int _streamQualityIndex = 0; // The current stream quality string - @override String get streamQuality => _availableStreamQualities.elementAtOrNull(_streamQualityIndex) ?? 'Auto'; @@ -274,11 +207,6 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { @readonly var _isInPipMode = false; - /// WebView path relies on the injected vaft userscript's own overlay; the - /// Dart overlay banner is driven by the native player only. - @override - bool get isBlockingAds => false; - /// The video URL to use for the webview. String get videoUrl => 'https://player.twitch.tv/?channel=$userLogin&muted=false&parent=frosty'; @@ -290,9 +218,10 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { required this.authStore, required this.settingsStore, }) { - // Reset chat delay to 0 to prevent starting with a stale value from - // a previous channel. - _chatLatencySync.reset(); + // Reset chat delay to 0 if auto sync is already enabled to prevent starting with old values + if (settingsStore.autoSyncChatDelay) { + settingsStore.chatDelay = 0.0; + } // Initialize the video webview params for iOS to enable video autoplay. if (WebViewPlatform.instance is WebKitWebViewPlatform) { _videoWebViewParams = WebKitWebViewControllerCreationParams( @@ -314,38 +243,40 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { // Initialize a reaction that incrementally applies/removes overlay customizations // without reloading the webview (which would tear down the HLS stream). - _disposeOverlayReaction = reaction((_) => settingsStore.showOverlay, ( - showOverlay, - ) async { - if (showOverlay) { - await _hideDefaultOverlay(); - if (settingsStore.showLatency || settingsStore.autoSyncChatDelay) { - await _listenOnLatencyChanges(); + _disposeOverlayReaction = reaction( + (_) => settingsStore.showOverlay, + (showOverlay) async { + if (showOverlay) { + await _hideDefaultOverlay(); + if (settingsStore.showLatency || settingsStore.autoSyncChatDelay) { + await _listenOnLatencyChanges(); + } + await updateStreamQualities(); + } else { + _showDefaultOverlay(); + try { + videoWebViewController.runJavaScript( + 'window._latencyTracker?.stop()', + ); + } catch (e) { + debugPrint(e.toString()); + } } - await updateStreamQualities(); - } else { - _showDefaultOverlay(); - _stopLatencyTracker(); - } - }); + }, + ); - // Initialize a reaction to manage timers based on video mode + // Initialize a reaction to manage stream info timer based on video mode _disposeVideoModeReaction = reaction((_) => settingsStore.showVideo, ( showVideo, ) { if (showVideo) { // In video mode, stop the timer since overlay taps handle refreshing _stopStreamInfoTimer(); - _startJsCleanupTimer(); } else { // In chat-only mode, start the timer for automatic updates _startStreamInfoTimer(); - _stopJsCleanupTimer(); // Ensure overlay timer is active for clean UI _scheduleOverlayHide(); - // The tracker's 60s cycle opens the Twitch settings menu via DOM - // queries — keep that off when the player isn't even visible. - _stopLatencyTracker(); } }); @@ -353,22 +284,28 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { if (!settingsStore.showVideo) { _startStreamInfoTimer(); _scheduleOverlayHide(); - } else { - _startJsCleanupTimer(); } // On Android, enable auto PiP mode (setAutoEnterEnabled) if the device supports it. if (Platform.isAndroid) { _disposeAndroidAutoPipReaction = autorun((_) async { - // setAutoPipMode is only implemented on Android S+ — invoking it on - // older versions throws a PlatformException, so skip when unavailable. - if (!await SimplePip.isAutoPipAvailable) return; - pip.setAutoPipMode(autoEnter: settingsStore.showVideo); + if (settingsStore.showVideo && await SimplePip.isAutoPipAvailable) { + pip.setAutoPipMode(); + } else { + pip.setAutoPipMode(autoEnter: false); + } }); } updateStreamInfo(); + // Initialize periodic JavaScript cleanup timer (every 10 minutes) + // This prevents memory accumulation during long viewing sessions + _jsCleanupTimer = Timer.periodic( + const Duration(minutes: 10), + (_) => _performJsSoftReset(), + ); + // React to changes in latency-related settings mid-session // This handles the case where user toggles autoSyncChatDelay or showLatency // while already watching a stream @@ -376,37 +313,27 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { (_) => (settingsStore.showLatency, settingsStore.autoSyncChatDelay), (values) async { final (showLatency, autoSync) = values; - // Tracker only runs against the live player iframe — skip in - // chat-only mode and when the custom overlay is off. - if (!settingsStore.showVideo || !settingsStore.showOverlay) return; + // Only act if overlay is enabled (latency tracker only works with custom overlay) + if (!settingsStore.showOverlay) return; if (showLatency || autoSync) { // Start tracker if either setting is now enabled // The init() method is idempotent - won't double-start if already running await _listenOnLatencyChanges(); - - // If autoSync was just enabled and we already have a cached latency - // value (e.g. from showLatency tracking), apply it immediately instead - // of waiting for the next tracker cycle (up to 60s). - if (autoSync && _latency != null) { - final numericPart = _latency!.replaceAll( - RegExp(r'[^0-9.]'), - '', - ); - final latencyAsDouble = double.tryParse(numericPart); - if (latencyAsDouble != null) { - _chatLatencySync.report(latencyAsDouble); - } - } } else { // Stop tracker if both settings are now disabled - _stopLatencyTracker(); + try { + videoWebViewController.runJavaScript( + 'window._latencyTracker?.stop()', + ); + } catch (e) { + debugPrint(e.toString()); + } } }, ); } - @override @action Future updateStreamQualities() async { try { @@ -446,7 +373,6 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { } } - @override @action Future setStreamQuality(String newStreamQuality) async { final indexOfStreamQuality = _availableStreamQualities.indexOf( @@ -557,18 +483,142 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { /// - Skips cycles entirely when overlay hidden (saves CPU) Future _listenOnLatencyChanges() async { try { - await videoWebViewController.runJavaScript(_kLatencyTrackerJs); - _updateLatencyTrackerVisibility(_overlayVisible); - } catch (e) { - debugPrint(e.toString()); - } - } + await videoWebViewController.runJavaScript(r''' + window._latencyTracker = { + CYCLE_INTERVAL: 60000, + STATS_ACTIVE_TIME: 1500, + INITIAL_RETRY_INTERVAL: 3000, + MAX_INITIAL_RETRIES: 4, - /// Tells the in-webview latency tracker to stop its polling cycle. - /// Safe to call when the tracker hasn't been injected yet. - void _stopLatencyTracker() { - try { - videoWebViewController.runJavaScript('window._latencyTracker?.stop()'); + cycleCount: 0, + hasInitialLatency: false, + timeoutId: null, + isRunning: false, + overlayVisible: true, + + init() { + if (this.isRunning) return; + this.isRunning = true; + this._cycle(); + }, + + stop() { + this.isRunning = false; + if (this.timeoutId) { + clearTimeout(this.timeoutId); + this.timeoutId = null; + } + }, + + setOverlayVisible(visible) { + this.overlayVisible = visible; + // Resume immediately when overlay becomes visible + if (visible && this.isRunning && !this.timeoutId) { + this._cycle(); + } + }, + + async _cycle() { + // Clear timeout ID synchronously at entry to prevent race conditions + // (e.g., setOverlayVisible calling _cycle while timeout is firing) + const currentTimeoutId = this.timeoutId; + this.timeoutId = null; + if (currentTimeoutId) { + clearTimeout(currentTimeoutId); + } + + if (!this.isRunning) return; + + // Skip cycle entirely if overlay not visible - major CPU savings + if (!this.overlayVisible) { + this.timeoutId = setTimeout(() => this._cycle(), 5000); + return; + } + + // Defer if another operation (e.g., quality selection) is using the settings menu + if (window._promiseQueueLength > 0) { + this.timeoutId = setTimeout(() => this._cycle(), 2000); + return; + } + + this.cycleCount++; + const cycleStart = Date.now(); + + await this._setStatsEnabled(true); + + // Wait longer on first cycle for stats to populate + const waitTime = this.cycleCount === 1 ? 3000 : this.STATS_ACTIVE_TIME; + await new Promise(resolve => setTimeout(resolve, waitTime)); + + this._readLatency(); + await this._setStatsEnabled(false); + + const totalActiveTime = Date.now() - cycleStart; + + // Calculate next interval + let nextInterval; + if (!this.hasInitialLatency && this.cycleCount < this.MAX_INITIAL_RETRIES) { + nextInterval = this.INITIAL_RETRY_INTERVAL; + } else { + nextInterval = this.CYCLE_INTERVAL - totalActiveTime; + } + + // Schedule next cycle + this.timeoutId = setTimeout(() => this._cycle(), Math.max(nextInterval, 1000)); + }, + + async _setStatsEnabled(enable) { + try { + await _queuePromise(async () => { + const settingsBtn = await _asyncQuerySelector('[data-a-target="player-settings-button"]'); + if (!settingsBtn) return; + settingsBtn.click(); + + const advancedItem = await _asyncQuerySelector('[data-a-target="player-settings-menu-item-advanced"]'); + if (!advancedItem) { + settingsBtn.click(); + return; + } + advancedItem.click(); + + const statsCheckbox = await _asyncQuerySelector('[data-a-target="player-settings-submenu-advanced-video-stats"] input'); + if (statsCheckbox && statsCheckbox.checked !== enable) { + statsCheckbox.click(); + } + + settingsBtn.click(); + }); + } catch (error) { + // Silently fail - stats panel may not be available + } + }, + + _readLatency() { + try { + const latencyElement = document.querySelector('[aria-label="Latency To Broadcaster"]'); + if (latencyElement && latencyElement.textContent) { + let latencyText = latencyElement.textContent.trim(); + + // Convert to whole number with abbreviated unit: "4.69 sec." -> "5s" + const match = latencyText.match(/([0-9.]+)\s*sec/i); + if (match) { + const rounded = Math.round(parseFloat(match[1])); + latencyText = rounded + 's'; + this.hasInitialLatency = true; + } + + if (window.Latency) { + Latency.postMessage(latencyText); + } + } + } catch (error) { + // Silently fail + } + } + }; + + window._latencyTracker.init(); + '''); } catch (e) { debugPrint(e.toString()); } @@ -636,40 +686,137 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { /// Initializes the video webview. @action Future initVideo() async { - if (await videoWebViewController.currentUrl() != videoUrl) return; - try { - await videoWebViewController.runJavaScript(_kInitVideoJs); - if (!settingsStore.showOverlay) return; - await _hideDefaultOverlay(); - // Start latency tracking if either showLatency (user-visible) or - // autoSyncChatDelay (needs latency to sync chat) is enabled. - if (settingsStore.showLatency || settingsStore.autoSyncChatDelay) { - await _listenOnLatencyChanges(); - } - await updateStreamQualities(); - } catch (e) { - debugPrint(e.toString()); - } - } + if (await videoWebViewController.currentUrl() == videoUrl) { + // Declare `window` level utility methods and add event listeners to notify the JavaScript channels when the video plays and pauses. + try { + await videoWebViewController.runJavaScript(''' + // Promise queue with length limit and generation tracking + // Generation tracking prevents counter from going negative when queue resets + window._PROMISE_QUEUE = Promise.resolve(); + window._promiseQueueLength = 0; + window._promiseQueueGen = 0; - /// Injects [assets/js/vaft.user.js] (TwitchAdSolutions) into the player page. - Future _injectVaftUserScript() async { - try { - final raw = await rootBundle.loadString('assets/js/vaft.user.js'); - // Strip UserScript metadata; keep the IIFE body. - final script = raw.replaceFirst( - RegExp(r'^[\s\S]*?==/UserScript==\s*', multiLine: true), - '', - ); - await videoWebViewController.runJavaScript(script); - debugPrint('VideoStore: injected TwitchAdSolutions vaft'); - } catch (e) { - debugPrint('VideoStore: vaft inject failed: $e'); + window._queuePromise = (method) => { + window._promiseQueueLength++; + + // Reset queue if it gets too long (prevents memory accumulation) + if (window._promiseQueueLength > 30) { + window._PROMISE_QUEUE = Promise.resolve(); + window._promiseQueueLength = 1; + window._promiseQueueGen++; + } + + const myGen = window._promiseQueueGen; + + window._PROMISE_QUEUE = window._PROMISE_QUEUE.then(async () => { + try { + await method(); + } catch (e) { + console.warn('Queue promise error:', e); + } finally { + // Only decrement if still same generation (not reset) + if (myGen === window._promiseQueueGen) { + window._promiseQueueLength--; + } + } + }); + + return window._PROMISE_QUEUE; + }; + window._asyncQuerySelector = (selector, timeout = 10000) => new Promise((resolve) => { + let element = document.querySelector(selector); + if (element) { + return resolve(element); + } + + let timeoutId; + const observer = new MutationObserver(() => { + element = document.querySelector(selector); + if (element) { + observer.disconnect(); + clearTimeout(timeoutId); + resolve(element); + } + }); + // Must use subtree to detect nested elements in the player + observer.observe(document.body, { childList: true, subtree: true }); + + // Always set timeout with default of 10 seconds + timeoutId = setTimeout(() => { + observer.disconnect(); + resolve(undefined); + }, timeout); + }); + + _queuePromise(async () => { + const videoElement = await _asyncQuerySelector("video"); + + // Null check in case element was not found within timeout + if (!videoElement) { + console.warn("Video element not found within timeout"); + return; + } + + // Cache for reuse in play/pause/PiP calls + window._videoEl = videoElement; + + // Prevent duplicate event listener registration + if (!videoElement._listenersAdded) { + videoElement._listenersAdded = true; + + videoElement.addEventListener("pause", () => { + VideoPause.postMessage("video paused"); + if (videoElement.textTracks && videoElement.textTracks.length > 0) { + videoElement.textTracks[0].mode = "hidden"; + } + }); + videoElement.addEventListener("playing", () => { + VideoPlaying.postMessage("video playing"); + // Ensure video is unmuted and captions are hidden + videoElement.muted = false; + videoElement.volume = 1.0; + if (videoElement.textTracks && videoElement.textTracks.length > 0) { + videoElement.textTracks[0].mode = "hidden"; + } + }); + + // Add PiP event listeners for iOS + videoElement.addEventListener("enterpictureinpicture", () => { + PipEntered.postMessage("pip entered"); + }); + videoElement.addEventListener("leavepictureinpicture", () => { + PipExited.postMessage("pip exited"); + }); + } + + if (!videoElement.paused) { + VideoPlaying.postMessage("video playing"); + // Ensure video is unmuted and captions are hidden + videoElement.muted = false; + videoElement.volume = 1.0; + if (videoElement.textTracks && videoElement.textTracks.length > 0) { + videoElement.textTracks[0].mode = "hidden"; + } + } + }); + '''); + if (settingsStore.showOverlay) { + await _hideDefaultOverlay(); + // Start latency tracking if either: + // - showLatency is enabled (user wants to see it on overlay), OR + // - autoSyncChatDelay is enabled (needs latency data for syncing) + if (settingsStore.showLatency || settingsStore.autoSyncChatDelay) { + await _listenOnLatencyChanges(); + } + await updateStreamQualities(); + } + } catch (e) { + debugPrint(e.toString()); + } } } /// Called whenever the video/overlay is tapped. - @override @action void handleVideoTap() { if (_isInPipMode) { @@ -698,7 +845,7 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { // Only start if not already active if (_streamInfoTimer?.isActive != true) { _streamInfoTimer = Timer.periodic( - VideoTimingConstants.webviewStreamInfoInterval, + const Duration(seconds: 60), (_) => updateStreamInfo(), ); } @@ -712,23 +859,7 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { } } - void _startJsCleanupTimer() { - if (_jsCleanupTimer?.isActive == true) return; - - _jsCleanupTimer = Timer.periodic( - VideoTimingConstants.jsCleanupInterval, - (_) => _performJsSoftReset(), - ); - } - - void _stopJsCleanupTimer() { - _jsCleanupTimer?.cancel(); - _jsCleanupTimer = null; - } - - void _scheduleOverlayHide([ - Duration delay = VideoTimingConstants.overlayAutoHide, - ]) { + void _scheduleOverlayHide([Duration delay = const Duration(seconds: 5)]) { _overlayTimer?.cancel(); if (_isInPipMode) { @@ -747,104 +878,58 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { }); } - @override - @action - void handleAndroidPipChanged(bool isInPip) => _setPipActive(isInPip); - - /// Shared PiP enter/exit state machine. - /// - /// Driven by the Android activity callback and, on iOS, by the web PiP API - /// events posted from the page. Android previously reached neither — the - /// callback was routed only to the native store — so `_isInPipMode` stayed - /// false for the whole PiP session and the overlay kept auto-hiding. - @action - void _setPipActive(bool isInPip) { - if (isInPip == _isInPipMode) return; - - if (isInPip) { - _overlayWasVisibleBeforePip = _overlayVisible; - _isInPipMode = true; - _overlayTimer?.cancel(); - _overlayVisible = true; - } else { - _isInPipMode = false; - if (_overlayWasVisibleBeforePip) { - _updateLatencyTrackerVisibility(true); - _scheduleOverlayHide(); - } else { - _overlayVisible = false; - _updateLatencyTrackerVisibility(false); - } - } - } - - /// Resumes playback when the page paused itself rather than the user doing - /// it. Safe to call spuriously — `play()` on an already-playing element is - /// a no-op. - /// - /// Deliberately not called on PiP transitions: Twitch's embed refuses to - /// play below 400x300 CSS pixels, and an Android PiP window is far under - /// that, so a resume there is rejected no matter what we do. - void _resumeIfSuspended() { - if (!settingsStore.showVideo || !_paused || _userPaused) return; - try { - videoWebViewController.runJavaScript( - '(window._videoEl || document.getElementsByTagName("video")[0])?.play();', - ); - } catch (e) { - debugPrint(e.toString()); - } - } - - /// Handles the app returning to the foreground. - /// - /// In chat-only mode this just refreshes the stream info. In video mode it - /// also resumes playback if the page paused itself while backgrounded — - /// the webview can be suspended when detached from the window and has no - /// way to restart on its own, which previously left the player dead until - /// a manual refresh. A pause the user asked for is left alone. - @override + /// Handles app resume event for immediate stream info refresh in chat-only mode. @action void handleAppResume() { + // Only refresh immediately in chat-only mode if (!settingsStore.showVideo) { updateStreamInfo(forceUpdate: true); - return; } - - _resumeIfSuspended(); } /// Updates the stream info from the Twitch API. /// /// If the stream is offline, fetches channel information to show offline details. /// Set [forceUpdate] to true to bypass the rate limiting check. - @override @action Future updateStreamInfo({bool forceUpdate = false}) async { - final result = await _streamInfoPoller.fetch(forceUpdate: forceUpdate); - if (result == null) return; - runInAction(() { - if (result.stream != null) { - _streamInfo = result.stream; - _offlineChannelInfo = null; - return; + // Rate limiting: prevent too frequent updates unless forced + final now = DateTime.now(); + if (!forceUpdate && _lastStreamInfoUpdate != null) { + final timeSinceLastUpdate = now.difference(_lastStreamInfoUpdate!); + if (timeSinceLastUpdate.inSeconds < 5) { + return; // Skip update if less than 5 seconds since last update } + } + + _lastStreamInfoUpdate = now; + + try { + _streamInfo = await twitchApi.getStream(userLogin: userLogin); + // Clear offline info when stream is live + _offlineChannelInfo = null; + } catch (e) { _overlayTimer?.cancel(); - _loading = false; _streamInfo = null; _paused = true; - _offlineChannelInfo = result.offlineChannel; + + // Try to fetch offline channel information + try { + _offlineChannelInfo = await twitchApi.getChannel(userId: userId); + } catch (channelError) { + _offlineChannelInfo = null; + } + // Restart overlay timer in chat-only mode even on error if (!settingsStore.showVideo) { _scheduleOverlayHide(); } - }); + } } /// Handles the toggle overlay options. /// /// The toggle overlay option allows switching between the custom and Twitch's overlay by long-pressing the overlay. - @override @action void handleToggleOverlay() { if (settingsStore.toggleableOverlay) { @@ -854,7 +939,7 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { if (settingsStore.showOverlay) { _overlayVisible = true; - _scheduleOverlayHide(VideoTimingConstants.overlayQuickHide); + _scheduleOverlayHide(const Duration(seconds: 3)); } } @@ -862,39 +947,34 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { } /// Refreshes the stream webview and updates the stream info. - @override + /// + /// Performs a hard refresh by loading about:blank first to completely clear + /// the webview state, then immediately reloads the video URL. This is more + /// reliable than a soft reload when the webview is sluggish. @action Future handleRefresh() async { HapticFeedback.lightImpact(); - _loading = true; _paused = true; - _userPaused = false; _firstTimeSettingQuality = true; - _highLatencyCount = 0; _isInPipMode = false; - _latency = null; - _availableStreamQualities = []; - _overlayTimer?.cancel(); - _scheduleOverlayHide(); - - // Belt-and-braces: onPageStarted re-arms this for the loadRequest below - // too, but don't depend on the delegate firing for an explicit refresh. - _needsInit = true; - - _stopLatencyTracker(); + // Stop latency tracker immediately to free up JS main thread for refresh + try { + videoWebViewController.runJavaScript('window._latencyTracker?.stop()'); + } catch (e) { + // Ignore - may not exist yet + } + // Hard refresh: clear everything by loading blank page first + await videoWebViewController.loadRequest(Uri.parse('about:blank')); + // Then immediately load the video URL for a fresh start await videoWebViewController.loadRequest(Uri.parse(videoUrl)); updateStreamInfo(); } /// Play or pause the video depending on the current state of [_paused]. - @override void handlePausePlay() { - // Remember the user's intent so handleAppResume knows whether a pause is - // theirs or something the page did to itself. - _userPaused = !_paused; try { if (_paused) { videoWebViewController.runJavaScript( @@ -914,7 +994,6 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { /// /// On Android, this will utilize the native Android PiP API. /// On iOS, this will utilize the web picture-in-picture API. - @override void requestPictureInPicture() { try { if (Platform.isAndroid) { @@ -934,7 +1013,6 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { /// If not in PiP mode, enters PiP mode. /// If already in PiP mode on iOS, exits PiP mode. /// On Android, always enters PiP mode (no programmatic exit or state tracking). - @override @action void togglePictureInPicture() { try { @@ -956,7 +1034,6 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { } } - @override @action void dispose() { // Disable auto PiP when leaving so that we don't enter PiP on other screens. @@ -978,282 +1055,10 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { // Explicitly stop latency tracker as defense-in-depth. // The Video widget also loads about:blank during disposal which clears // all JavaScript state, but this provides an extra safety layer. - _stopLatencyTracker(); + try { + videoWebViewController.runJavaScript('window._latencyTracker?.stop()'); + } catch (e) { + // Ignore - page may already be unloading + } } } - -// --------------------------------------------------------------------------- -// JS templates evaluated inside the Twitch player WebView. -// These are at the end of the file so the Dart code above stays readable. -// --------------------------------------------------------------------------- - -/// Bootstraps the Twitch player iframe: promise queue, async query selector, -/// and play/pause/PiP event listeners that post back through Flutter's -/// JavaScriptChannels. Run once per page load from [VideoStoreBase.initVideo]. -const _kInitVideoJs = r''' - // Promise queue with length limit and generation tracking - // Generation tracking prevents counter from going negative when queue resets - window._PROMISE_QUEUE = Promise.resolve(); - window._promiseQueueLength = 0; - window._promiseQueueGen = 0; - - window._queuePromise = (method) => { - window._promiseQueueLength++; - - // Reset queue if it gets too long (prevents memory accumulation) - if (window._promiseQueueLength > 30) { - window._PROMISE_QUEUE = Promise.resolve(); - window._promiseQueueLength = 1; - window._promiseQueueGen++; - } - - const myGen = window._promiseQueueGen; - - window._PROMISE_QUEUE = window._PROMISE_QUEUE.then(async () => { - try { - await method(); - } catch (e) { - console.warn('Queue promise error:', e); - } finally { - // Only decrement if still same generation (not reset) - if (myGen === window._promiseQueueGen) { - window._promiseQueueLength--; - } - } - }); - - return window._PROMISE_QUEUE; - }; - window._asyncQuerySelector = (selector, timeout = 10000) => new Promise((resolve) => { - let element = document.querySelector(selector); - if (element) { - return resolve(element); - } - - let timeoutId; - const observer = new MutationObserver(() => { - element = document.querySelector(selector); - if (element) { - observer.disconnect(); - clearTimeout(timeoutId); - resolve(element); - } - }); - // Must use subtree to detect nested elements in the player - observer.observe(document.body, { childList: true, subtree: true }); - - // Always set timeout with default of 10 seconds - timeoutId = setTimeout(() => { - observer.disconnect(); - resolve(undefined); - }, timeout); - }); - - _queuePromise(async () => { - const videoElement = await _asyncQuerySelector("video"); - - // Null check in case element was not found within timeout - if (!videoElement) { - console.warn("Video element not found within timeout"); - return; - } - - // Cache for reuse in play/pause/PiP calls - window._videoEl = videoElement; - - // Prevent duplicate event listener registration - if (!videoElement._listenersAdded) { - videoElement._listenersAdded = true; - - videoElement.addEventListener("pause", () => { - VideoPause.postMessage("video paused"); - if (videoElement.textTracks && videoElement.textTracks.length > 0) { - videoElement.textTracks[0].mode = "hidden"; - } - }); - videoElement.addEventListener("playing", () => { - VideoPlaying.postMessage("video playing"); - // Ensure video is unmuted and captions are hidden - videoElement.muted = false; - videoElement.volume = 1.0; - if (videoElement.textTracks && videoElement.textTracks.length > 0) { - videoElement.textTracks[0].mode = "hidden"; - } - }); - - // Add PiP event listeners for iOS - videoElement.addEventListener("enterpictureinpicture", () => { - PipEntered.postMessage("pip entered"); - }); - videoElement.addEventListener("leavepictureinpicture", () => { - PipExited.postMessage("pip exited"); - }); - } - - if (!videoElement.paused) { - VideoPlaying.postMessage("video playing"); - // Ensure video is unmuted and captions are hidden - videoElement.muted = false; - videoElement.volume = 1.0; - if (videoElement.textTracks && videoElement.textTracks.length > 0) { - videoElement.textTracks[0].mode = "hidden"; - } - } - - // Explicitly attempt autoplay in case the player loaded paused - try { await videoElement.play(); } catch (e) {} - }); -'''; - -/// Periodic latency-tracker state machine running inside the Twitch player -/// WebView. Opens the stats panel, reads "Latency To Broadcaster", posts the -/// value through the `Latency` JavaScriptChannel, and closes the panel. -/// Pauses its cycle when the overlay is hidden (saves CPU). -const _kLatencyTrackerJs = r''' - window._latencyTracker ??= { - CYCLE_INTERVAL: 60000, - STATS_ACTIVE_TIME: 1500, - INITIAL_RETRY_INTERVAL: 3000, - MAX_INITIAL_RETRIES: 4, - - cycleCount: 0, - hasInitialLatency: false, - timeoutId: null, - isRunning: false, - overlayVisible: true, - isCycling: false, - - init() { - if (this.isRunning) return; - this.isRunning = true; - if (!this.isCycling && !this.timeoutId) { - this._cycle(); - } - }, - - stop() { - this.isRunning = false; - if (this.timeoutId) { - clearTimeout(this.timeoutId); - this.timeoutId = null; - } - }, - - setOverlayVisible(visible) { - this.overlayVisible = visible; - // Resume immediately when overlay becomes visible - if (visible && this.isRunning && !this.isCycling && !this.timeoutId) { - this._cycle(); - } - }, - - async _cycle() { - if (this.isCycling) return; - this.isCycling = true; - - try { - // Clear timeout ID synchronously at entry to prevent race conditions - // (e.g., setOverlayVisible calling _cycle while timeout is firing) - const currentTimeoutId = this.timeoutId; - this.timeoutId = null; - if (currentTimeoutId) { - clearTimeout(currentTimeoutId); - } - - if (!this.isRunning) return; - - // Skip cycle entirely if overlay not visible - major CPU savings - if (!this.overlayVisible) { - this.timeoutId = setTimeout(() => this._cycle(), 5000); - return; - } - - // Defer if another operation (e.g., quality selection) is using the settings menu - if (window._promiseQueueLength > 0) { - this.timeoutId = setTimeout(() => this._cycle(), 2000); - return; - } - - this.cycleCount++; - const cycleStart = Date.now(); - - await this._setStatsEnabled(true); - - // Wait longer on first cycle for stats to populate - const waitTime = this.cycleCount === 1 ? 3000 : this.STATS_ACTIVE_TIME; - await new Promise(resolve => setTimeout(resolve, waitTime)); - - this._readLatency(); - await this._setStatsEnabled(false); - - if (!this.isRunning) return; - - const totalActiveTime = Date.now() - cycleStart; - - // Calculate next interval - let nextInterval; - if (!this.hasInitialLatency && this.cycleCount < this.MAX_INITIAL_RETRIES) { - nextInterval = this.INITIAL_RETRY_INTERVAL; - } else { - nextInterval = this.CYCLE_INTERVAL - totalActiveTime; - } - - // Schedule next cycle - this.timeoutId = setTimeout(() => this._cycle(), Math.max(nextInterval, 1000)); - } finally { - this.isCycling = false; - } - }, - - async _setStatsEnabled(enable) { - try { - await _queuePromise(async () => { - const settingsBtn = await _asyncQuerySelector('[data-a-target="player-settings-button"]'); - if (!settingsBtn) return; - settingsBtn.click(); - - const advancedItem = await _asyncQuerySelector('[data-a-target="player-settings-menu-item-advanced"]'); - if (!advancedItem) { - settingsBtn.click(); - return; - } - advancedItem.click(); - - const statsCheckbox = await _asyncQuerySelector('[data-a-target="player-settings-submenu-advanced-video-stats"] input'); - if (statsCheckbox && statsCheckbox.checked !== enable) { - statsCheckbox.click(); - } - - settingsBtn.click(); - }); - } catch (error) { - // Silently fail - stats panel may not be available - } - }, - - _readLatency() { - try { - const latencyElement = document.querySelector('[aria-label="Latency To Broadcaster"]'); - if (latencyElement && latencyElement.textContent) { - let latencyText = latencyElement.textContent.trim(); - - // Convert to whole number with abbreviated unit: "4.69 sec." -> "5s" - const match = latencyText.match(/([0-9.]+)\s*sec/i); - if (match) { - const rounded = Math.round(parseFloat(match[1])); - latencyText = rounded + 's'; - this.hasInitialLatency = true; - } - - if (window.Latency) { - Latency.postMessage(latencyText); - } - } - } catch (error) { - // Silently fail - } - } - }; - - window._latencyTracker.init(); -'''; diff --git a/lib/screens/channel/video/video_store.g.dart b/lib/screens/channel/video/video_store.g.dart index 13e704fc..30c79efc 100644 --- a/lib/screens/channel/video/video_store.g.dart +++ b/lib/screens/channel/video/video_store.g.dart @@ -9,26 +9,6 @@ part of 'video_store.dart'; // ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers mixin _$VideoStore on VideoStoreBase, Store { - late final _$_loadingAtom = Atom( - name: 'VideoStoreBase._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 _$_pausedAtom = Atom( name: 'VideoStoreBase._paused', context: context, @@ -278,30 +258,6 @@ mixin _$VideoStore on VideoStoreBase, Store { } } - @override - void handleAndroidPipChanged(bool isInPip) { - final _$actionInfo = _$VideoStoreBaseActionController.startAction( - name: 'VideoStoreBase.handleAndroidPipChanged', - ); - try { - return super.handleAndroidPipChanged(isInPip); - } finally { - _$VideoStoreBaseActionController.endAction(_$actionInfo); - } - } - - @override - void _setPipActive(bool isInPip) { - final _$actionInfo = _$VideoStoreBaseActionController.startAction( - name: 'VideoStoreBase._setPipActive', - ); - try { - return super._setPipActive(isInPip); - } finally { - _$VideoStoreBaseActionController.endAction(_$actionInfo); - } - } - @override void handleAppResume() { final _$actionInfo = _$VideoStoreBaseActionController.startAction( diff --git a/lib/screens/channel/video/video_timing_constants.dart b/lib/screens/channel/video/video_timing_constants.dart deleted file mode 100644 index 3fe54344..00000000 --- a/lib/screens/channel/video/video_timing_constants.dart +++ /dev/null @@ -1,67 +0,0 @@ -/// Named durations and counts used by the video player stores. -/// Central owner so tuning values don't have to be hunted across files. -class VideoTimingConstants { - VideoTimingConstants._(); - - // Stall recovery - // - // Cross-layer ordering contract: iOS's native notification-stall recovery - // fires at ~1s, this Dart timer at 8s, and the Android native watchdog - // (VideoPlayerObserver.kt STALL_WATCHDOG_MS) at 12s. Correctness depends - // on 1s < stallDetectionDelay < 12s — Dart's attempt-1 seek must pre-empt - // the Android watchdog's heavier stop()/prepare() recovery, and iOS has - // no native watchdog so the Dart timer is the only recoverer for silent - // rate-drops there. Don't tune past either neighbor. - static const stallDetectionDelay = Duration(seconds: 8); - static const initRetryDelay = Duration(seconds: 3); - static const int maxRefreshAttempts = 3; - - /// Delay between stall-recovery full refreshes, indexed by attempt number - /// (0 = first attempt). Attempts beyond the list length fall through to - /// the error path via [maxRefreshAttempts]. - static const refreshBackoff = [ - Duration.zero, - Duration(seconds: 3), - Duration(seconds: 8), - ]; - - /// A "play" event arriving within this window after a previous one is - /// treated as a failed-recovery bounce rather than a fresh successful start. - static const shortPlayWindow = Duration(seconds: 10); - /// Number of short-play bounces required before surfacing a tailored error. - static const int shortPlayLoopThreshold = 3; - - // Latency / chat sync - static const latencyPollingInterval = Duration(seconds: 3); - static const int highLatencyThresholdSeconds = 30; - static const int chatSyncDriftToleranceSeconds = 2; - - /// Maximum consecutive high-latency interventions (seek/refresh) before - /// giving up on latency-driven recovery. Persistent high readings usually - /// mean device clock skew or a device that can't keep up — looping - /// recoveries forever makes playback worse, not better. - static const int maxHighLatencyRecoveries = 3; - - /// How long a quality switch may suppress stall classification before the - /// flag expires. A resolution-only switch never interrupts playback, so no - /// play/pause event arrives to clear it — without this backstop the flag - /// sticks for the whole session and disables stall recovery. - static const qualitySwitchGrace = Duration(seconds: 5); - - /// Delay before re-checking /streams when the first check reports offline. - /// Helix transiently drops live streams from results; a single failed - /// check isn't enough to tear down healthy playback. - static const offlineConfirmationDelay = Duration(seconds: 2); - - // Overlay - static const overlayAutoHide = Duration(seconds: 5); - static const overlayQuickHide = Duration(seconds: 3); - - // Stream info - static const streamInfoDebounce = Duration(seconds: 5); - static const webviewStreamInfoInterval = Duration(minutes: 1); - static const nativeStreamInfoInterval = Duration(seconds: 30); - - // WebView housekeeping - static const jsCleanupInterval = Duration(minutes: 10); -} diff --git a/lib/screens/home/home.dart b/lib/screens/home/home.dart index 64c32616..2081dbbd 100644 --- a/lib/screens/home/home.dart +++ b/lib/screens/home/home.dart @@ -44,10 +44,7 @@ class _HomeState extends State { Navigator.push( context, - MaterialPageRoute( - settings: const RouteSettings(name: ReleaseNotes.routeName), - builder: (context) => const ReleaseNotes(), - ), + MaterialPageRoute(builder: (context) => const ReleaseNotes()), ).then((_) => prefs.setString('last_shown_version', currentVersion)); } } @@ -55,12 +52,13 @@ class _HomeState extends State { @override void initState() { super.initState(); - SystemChrome.setPreferredOrientations([]); checkAndShowReleaseNotes(); } @override Widget build(BuildContext context) { + SystemChrome.setPreferredOrientations([]); + final theme = Theme.of(context); return GestureDetector( @@ -124,7 +122,6 @@ class _HomeState extends State { onPressed: () => Navigator.push( context, MaterialPageRoute( - settings: const RouteSettings(name: Settings.routeName), builder: (context) => Settings( settingsStore: context.read(), ), @@ -166,12 +163,15 @@ class _HomeState extends State { NavigationDestination( icon: Icon( Icons.favorite_border_rounded, - color: theme.colorScheme.onSurfaceVariant - .withValues(alpha: 0.5), + color: _homeStore.selectedIndex == 0 + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurfaceVariant.withValues( + alpha: 0.6, + ), ), selectedIcon: Icon( Icons.favorite_rounded, - color: theme.colorScheme.primary, + color: theme.colorScheme.onSurface, ), label: 'Following', tooltip: 'Following', @@ -179,12 +179,17 @@ class _HomeState extends State { NavigationDestination( icon: Icon( Icons.arrow_upward_rounded, - color: theme.colorScheme.onSurfaceVariant - .withValues(alpha: 0.5), + color: + _homeStore.selectedIndex == + (_authStore.isLoggedIn ? 1 : 0) + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurfaceVariant.withValues( + alpha: 0.6, + ), ), selectedIcon: Icon( Icons.arrow_upward_rounded, - color: theme.colorScheme.primary, + color: theme.colorScheme.onSurface, ), label: 'Top', tooltip: 'Top', @@ -192,24 +197,24 @@ class _HomeState extends State { NavigationDestination( icon: Icon( Icons.search_rounded, - color: theme.colorScheme.onSurfaceVariant - .withValues(alpha: 0.5), + color: + _homeStore.selectedIndex == + (_authStore.isLoggedIn ? 2 : 1) + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurfaceVariant.withValues( + alpha: 0.6, + ), ), selectedIcon: Icon( Icons.search_rounded, - color: theme.colorScheme.primary, + color: theme.colorScheme.onSurface, ), label: 'Search', tooltip: 'Search', ), ], selectedIndex: _homeStore.selectedIndex, - onDestinationSelected: (index) { - if (index != _homeStore.selectedIndex) { - HapticFeedback.selectionClick(); - } - _homeStore.handleTap(index); - }, + onDestinationSelected: _homeStore.handleTap, ), ), ), diff --git a/lib/screens/home/search/search.dart b/lib/screens/home/search/search.dart index c2fb3ab8..00c9d256 100644 --- a/lib/screens/home/search/search.dart +++ b/lib/screens/home/search/search.dart @@ -2,10 +2,8 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/apis/twitch_api.dart'; -import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/screens/home/search/search_results_categories.dart'; import 'package:frosty/screens/home/search/search_results_channels.dart'; -import 'package:frosty/screens/home/search/search_results_tags.dart'; import 'package:frosty/screens/home/search/search_store.dart'; import 'package:frosty/screens/home/stream_list/streams_list.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; @@ -15,7 +13,7 @@ import 'package:frosty/widgets/frosty_scrollbar.dart'; import 'package:frosty/widgets/section_header.dart'; import 'package:provider/provider.dart'; -/// The search section that contains search history and search results for channels and categories. +/// The search section that contians search history and search results for channels and categories. class Search extends StatefulWidget { // The scroll controller for handling scroll to top functionality. final ScrollController scrollController; @@ -33,7 +31,6 @@ class _SearchState extends State { late final _searchStore = SearchStore( authStore: context.read(), twitchApi: context.read(), - twitchGqlApi: context.read(), ); @override @@ -46,6 +43,7 @@ class _SearchState extends State { builder: (context) { if (_searchStore.searchText.isEmpty) { if (_searchStore.searchHistory.isEmpty) { + // Keep controller attached so borders stay in sync return CustomScrollView( controller: widget.scrollController, slivers: [ @@ -174,20 +172,6 @@ class _SearchState extends State { ), ), SearchResultsCategories(searchStore: _searchStore), - SliverToBoxAdapter( - child: Builder( - builder: (context) => SectionHeader( - 'Tags', - padding: EdgeInsets.fromLTRB( - 16 + MediaQuery.of(context).padding.left, - 8, - 16 + MediaQuery.of(context).padding.right, - 8, - ), - ), - ), - ), - SearchResultsTags(searchStore: _searchStore), // Add padding for bottom navigation bar const SliverBottomPadding(), ], @@ -221,7 +205,7 @@ class _SearchState extends State { onChanged: _searchStore.onSearchTextChanged, decoration: InputDecoration( prefixIcon: const Icon(Icons.search_rounded), - hintText: 'Search channels, categories, or tags', + hintText: 'Search channels or categories', suffixIcon: _searchStore.textFieldFocusNode.hasFocus || _searchStore.searchText.isNotEmpty @@ -236,6 +220,35 @@ class _SearchState extends State { } _searchStore.textEditingController.clear(); _searchStore.onSearchTextChanged(''); + // Ensure borders reset: scroll back to top and trigger listeners + if (widget.scrollController.hasClients) { + widget.scrollController.animateTo( + 0, + duration: const Duration( + milliseconds: 150, + ), + curve: Curves.easeOut, + ); + } + // After the frame (when content swaps), re-evaluate border state + WidgetsBinding.instance.addPostFrameCallback(( + _, + ) { + if (widget.scrollController.hasClients) { + // Nudge listeners even if already at zero + final offset = + widget.scrollController.offset; + final target = (offset == 0) + ? 0.01 + : 0.0; + widget.scrollController.jumpTo( + (offset == 0) ? target : 0.0, + ); + if (target == 0.01) { + widget.scrollController.jumpTo(0.0); + } + } + }); }, ) : null, diff --git a/lib/screens/home/search/search_results_channels.dart b/lib/screens/home/search/search_results_channels.dart index 7ae772be..4bde37a7 100644 --- a/lib/screens/home/search/search_results_channels.dart +++ b/lib/screens/home/search/search_results_channels.dart @@ -39,7 +39,6 @@ class _SearchResultsChannelsState extends State { Navigator.push( context, MaterialPageRoute( - settings: const RouteSettings(name: VideoChat.routeName), builder: (context) => VideoChat( userId: channelInfo.broadcasterId, userName: channelInfo.broadcasterName, @@ -121,7 +120,6 @@ class _SearchResultsChannelsState extends State { onTap: () => Navigator.push( context, MaterialPageRoute( - settings: const RouteSettings(name: VideoChat.routeName), builder: (context) => VideoChat( userId: channel.id, userName: channel.displayName, @@ -130,7 +128,7 @@ class _SearchResultsChannelsState extends State { ), ), onLongPress: () { - HapticFeedback.mediumImpact(); + HapticFeedback.lightImpact(); showModalBottomSheetWithProperFocus( context: context, diff --git a/lib/screens/home/search/search_results_tags.dart b/lib/screens/home/search/search_results_tags.dart deleted file mode 100644 index 4a445385..00000000 --- a/lib/screens/home/search/search_results_tags.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:frosty/screens/home/search/search_store.dart'; -import 'package:frosty/screens/home/search/tag_streams.dart'; -import 'package:frosty/widgets/alert_message.dart'; -import 'package:frosty/widgets/skeleton_loader.dart'; -import 'package:mobx/mobx.dart'; - -class SearchResultsTags extends StatelessWidget { - final SearchStore searchStore; - - const SearchResultsTags({super.key, required this.searchStore}); - - @override - Widget build(BuildContext context) { - return Observer( - builder: (context) { - final future = searchStore.tagFuture; - - if (future == null) { - if (searchStore.isSearching) { - return _tagSkeletons(); - } - return const SliverToBoxAdapter(child: SizedBox.shrink()); - } - - switch (future.status) { - case FutureStatus.pending: - return _tagSkeletons(); - case FutureStatus.rejected: - return const SliverToBoxAdapter( - child: SizedBox( - height: 100.0, - child: AlertMessage( - message: 'Unable to load tags', - vertical: true, - ), - ), - ); - case FutureStatus.fulfilled: - final List tags = future.result as List? ?? const []; - - if (tags.isEmpty) { - return const SliverToBoxAdapter( - child: SizedBox( - height: 100.0, - child: AlertMessage( - message: 'No matching tags', - vertical: true, - ), - ), - ); - } - - return SliverList.builder( - itemCount: tags.length, - itemBuilder: (context, index) { - final tag = tags[index]; - return ListTile( - leading: const Icon(Icons.tag_rounded), - title: Text(tag), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - settings: const RouteSettings( - name: TagStreams.routeName, - ), - builder: (context) => TagStreams(tagName: tag), - ), - ); - }, - ); - }, - ); - } - }, - ); - } - - Widget _tagSkeletons() { - return SliverList.builder( - itemCount: 4, - itemBuilder: (context, index) => ListTile( - leading: SkeletonLoader( - width: 24, - height: 24, - borderRadius: BorderRadius.circular(4), - ), - title: SkeletonLoader( - height: 16, - borderRadius: BorderRadius.circular(4), - ), - ), - ); - } -} diff --git a/lib/screens/home/search/search_store.dart b/lib/screens/home/search/search_store.dart index 5969a495..0e3df531 100644 --- a/lib/screens/home/search/search_store.dart +++ b/lib/screens/home/search/search_store.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; import 'package:frosty/apis/twitch_api.dart'; -import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/models/category.dart'; import 'package:frosty/models/channel.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; @@ -18,8 +17,6 @@ abstract class SearchStoreBase with Store { final TwitchApi twitchApi; - final TwitchGqlApi twitchGqlApi; - final textEditingController = TextEditingController(); final textFieldFocusNode = FocusNode(); @@ -43,20 +40,13 @@ abstract class SearchStoreBase with Store { @readonly ObservableFuture? _categoryFuture; - @readonly - ObservableFuture>? _tagFuture; - - SearchStoreBase({ - required this.authStore, - required this.twitchApi, - required this.twitchGqlApi, - }) { + SearchStoreBase({required this.authStore, required this.twitchApi}) { init(); } @action Future init() async { - // Load SharedPreferences for local search history. + // Retrieve the instance that will allow us to retrieve local search history. final prefs = await SharedPreferences.getInstance(); // Retrieve the search history from local storage. If it doesn't exist, use an empty list. @@ -85,7 +75,6 @@ abstract class SearchStoreBase with Store { _isSearching = false; _channelFuture = null; _categoryFuture = null; - _tagFuture = null; return; } @@ -98,6 +87,7 @@ abstract class SearchStoreBase with Store { } /// Obtain the channels and categories that match the provided [query]. + /// Adds the query to search history. @action void handleQuery(String query) { if (query.isEmpty) return; @@ -105,19 +95,19 @@ abstract class SearchStoreBase with Store { // Cancel any pending debounce since we're searching immediately. _debounceTimer?.cancel(); + // Move the query to the most recent result (top of the stack). + _searchHistory.remove(query); + _searchHistory.insert(0, query); + _performSearch(query); } - /// Performs the actual search API calls and saves the query to history. + /// Performs the actual search API calls. @action void _performSearch(String query) { // Futures are now pending, so isSearching can be cleared. _isSearching = false; - // Move the query to the most recent result (top of the stack). - _searchHistory.remove(query); - _searchHistory.insert(0, query); - // Fetch the matching channels, sort it by live status, and then set it. _channelFuture = twitchApi.searchChannels(query: query).then((channels) { channels.sort((c1, c2) => c2.isLive ? 1 : -1); @@ -138,8 +128,6 @@ abstract class SearchStoreBase with Store { } return categories; }).asObservable(); - - _tagFuture = twitchGqlApi.searchFreeformTags(query: query).asObservable(); } /// Find a specific channel provided the [query]. diff --git a/lib/screens/home/search/search_store.g.dart b/lib/screens/home/search/search_store.g.dart index ff03473e..ace3f885 100644 --- a/lib/screens/home/search/search_store.g.dart +++ b/lib/screens/home/search/search_store.g.dart @@ -109,26 +109,6 @@ mixin _$SearchStore on SearchStoreBase, Store { }); } - late final _$_tagFutureAtom = Atom( - name: 'SearchStoreBase._tagFuture', - context: context, - ); - - ObservableFuture>? get tagFuture { - _$_tagFutureAtom.reportRead(); - return super._tagFuture; - } - - @override - ObservableFuture>? get _tagFuture => tagFuture; - - @override - set _tagFuture(ObservableFuture>? value) { - _$_tagFutureAtom.reportWrite(value, super._tagFuture, () { - super._tagFuture = value; - }); - } - late final _$initAsyncAction = AsyncAction( 'SearchStoreBase.init', context: context, diff --git a/lib/screens/home/search/tag_streams.dart b/lib/screens/home/search/tag_streams.dart deleted file mode 100644 index c78c9051..00000000 --- a/lib/screens/home/search/tag_streams.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:frosty/screens/home/stream_list/stream_list_store.dart'; -import 'package:frosty/screens/home/stream_list/streams_list.dart'; -import 'package:frosty/screens/settings/stores/settings_store.dart'; -import 'package:frosty/widgets/blurred_container.dart'; -import 'package:frosty/widgets/stream_sort_button.dart'; -import 'package:provider/provider.dart'; - -/// Streams matching a freeform tag (search → tap tag). -class TagStreams extends StatelessWidget { - static const routeName = 'TagStreams'; - - final String tagName; - - const TagStreams({super.key, required this.tagName}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final settingsStore = context.watch(); - - return Scaffold( - backgroundColor: theme.scaffoldBackgroundColor, - extendBody: true, - extendBodyBehindAppBar: true, - body: Stack( - children: [ - Positioned.fill( - child: Observer( - builder: (_) => StreamsList( - listType: ListType.tag, - freeformTag: tagName, - sortOrder: settingsStore.streamViewerSort, - showJumpButton: true, - ), - ), - ), - Positioned( - top: 0, - left: 0, - right: 0, - child: BlurredContainer( - gradientDirection: GradientDirection.up, - padding: EdgeInsets.only( - top: MediaQuery.of(context).padding.top, - left: MediaQuery.of(context).padding.left, - right: MediaQuery.of(context).padding.right, - ), - child: SizedBox( - height: kToolbarHeight, - child: Row( - children: [ - IconButton( - tooltip: 'Back', - icon: Icon(Icons.adaptive.arrow_back_rounded), - onPressed: Navigator.of(context).pop, - ), - Expanded( - child: Text( - tagName, - style: theme.textTheme.titleLarge, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - Observer( - builder: (_) => StreamSortButton( - sortOrder: settingsStore.streamViewerSort, - onChanged: (order) => - settingsStore.streamViewerSort = order, - ), - ), - ], - ), - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/screens/home/stream_list/large_stream_card.dart b/lib/screens/home/stream_list/large_stream_card.dart index ab09ce96..95413874 100644 --- a/lib/screens/home/stream_list/large_stream_card.dart +++ b/lib/screens/home/stream_list/large_stream_card.dart @@ -2,7 +2,6 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:frosty/constants.dart'; import 'package:frosty/models/stream.dart'; import 'package:frosty/screens/channel/channel.dart'; import 'package:frosty/screens/channel/video/stream_info_bar.dart'; @@ -20,8 +19,6 @@ class LargeStreamCard extends StatelessWidget { final bool showCategory; final bool showPinOption; final bool? isPinned; - final bool? initialIsFollowing; - final ValueChanged? onFollowChanged; const LargeStreamCard({ super.key, @@ -30,8 +27,6 @@ class LargeStreamCard extends StatelessWidget { this.showCategory = true, this.showPinOption = false, this.isPinned, - this.initialIsFollowing, - this.onFollowChanged, }); @override @@ -52,7 +47,7 @@ class LargeStreamCard extends StatelessWidget { final thumbnail = SizedBox( width: double.infinity, child: ClipRRect( - borderRadius: kCardBorderRadius, + borderRadius: const BorderRadius.all(Radius.circular(8)), child: AspectRatio( aspectRatio: 16 / 9, child: FrostyCachedNetworkImage( @@ -62,7 +57,7 @@ class LargeStreamCard extends StatelessWidget { ), cacheKey: cacheKey, placeholder: (context, url) => const SkeletonLoader( - borderRadius: kCardBorderRadius, + borderRadius: BorderRadius.all(Radius.circular(8)), ), useOldImageOnUrlChange: true, ), @@ -79,7 +74,6 @@ class LargeStreamCard extends StatelessWidget { onTap: () => Navigator.push( context, MaterialPageRoute( - settings: const RouteSettings(name: VideoChat.routeName), builder: (context) => VideoChat( userId: streamInfo.userId, userName: streamInfo.userName, @@ -99,8 +93,6 @@ class LargeStreamCard extends StatelessWidget { userId: streamInfo.userId, showPinOption: showPinOption, isPinned: isPinned, - initialIsFollowing: initialIsFollowing, - onFollowChanged: onFollowChanged, ), ); }, diff --git a/lib/screens/home/stream_list/offline_channel_card.dart b/lib/screens/home/stream_list/offline_channel_card.dart index 2fe17d46..0c5005d1 100644 --- a/lib/screens/home/stream_list/offline_channel_card.dart +++ b/lib/screens/home/stream_list/offline_channel_card.dart @@ -15,8 +15,6 @@ class OfflineChannelCard extends StatelessWidget { final bool showPinOption; final bool? isPinned; final bool showOfflineStatus; - final bool? initialIsFollowing; - final ValueChanged? onFollowChanged; const OfflineChannelCard({ super.key, @@ -24,8 +22,6 @@ class OfflineChannelCard extends StatelessWidget { required this.showPinOption, this.isPinned, this.showOfflineStatus = false, - this.initialIsFollowing, - this.onFollowChanged, }); @override @@ -61,7 +57,6 @@ class OfflineChannelCard extends StatelessWidget { onTap: () => Navigator.push( context, MaterialPageRoute( - settings: const RouteSettings(name: VideoChat.routeName), builder: (context) => VideoChat( userId: channelInfo.broadcasterId, userName: channelInfo.broadcasterName, @@ -81,17 +76,13 @@ class OfflineChannelCard extends StatelessWidget { userId: channelInfo.broadcasterId, showPinOption: showPinOption, isPinned: isPinned, - initialIsFollowing: initialIsFollowing, - onFollowChanged: onFollowChanged, ), ); }, child: Padding( - padding: EdgeInsets.fromLTRB( - 16 + MediaQuery.of(context).padding.left, - 8, - 16 + MediaQuery.of(context).padding.right, - 8, + padding: EdgeInsets.symmetric( + horizontal: 16 + MediaQuery.of(context).padding.left, + vertical: 8, ), child: Row( spacing: 12, diff --git a/lib/screens/home/stream_list/stream_card.dart b/lib/screens/home/stream_list/stream_card.dart index 0ffa914c..24f26de6 100644 --- a/lib/screens/home/stream_list/stream_card.dart +++ b/lib/screens/home/stream_list/stream_card.dart @@ -2,13 +2,12 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:frosty/constants.dart'; import 'package:frosty/models/stream.dart'; import 'package:frosty/screens/channel/channel.dart'; import 'package:frosty/screens/home/top/categories/category_streams.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; +import 'package:frosty/theme.dart'; import 'package:frosty/utils.dart'; -import 'package:frosty/utils/context_extensions.dart'; 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'; @@ -26,8 +25,6 @@ class StreamCard extends StatelessWidget { final bool showCategory; final bool showPinOption; final bool? isPinned; - final bool? initialIsFollowing; - final ValueChanged? onFollowChanged; const StreamCard({ super.key, @@ -36,8 +33,6 @@ class StreamCard extends StatelessWidget { this.showCategory = true, this.showPinOption = false, this.isPinned, - this.initialIsFollowing, - this.onFollowChanged, }); @override @@ -64,7 +59,7 @@ class StreamCard extends StatelessWidget { ), cacheKey: cacheKey, placeholder: (context, url) => const SkeletonLoader( - borderRadius: kCardBorderRadius, + borderRadius: BorderRadius.all(Radius.circular(8)), ), useOldImageOnUrlChange: true, ), @@ -85,22 +80,19 @@ class StreamCard extends StatelessWidget { final fontColor = DefaultTextStyle.of(context).style.color; final imageSection = ClipRRect( - borderRadius: kCardBorderRadius, + borderRadius: const BorderRadius.all(Radius.circular(8)), child: Stack( alignment: AlignmentDirectional.bottomEnd, children: [ GestureDetector( - onLongPress: () { - HapticFeedback.lightImpact(); - showDialog( - context: context, - builder: (context) => FrostyPhotoViewDialog( - imageUrl: streamInfo - .thumbnailUrl, // Pass original URL with {width}x{height} placeholder - cacheKey: cacheKey, - ), - ); - }, + onLongPress: () => showDialog( + context: context, + builder: (context) => FrostyPhotoViewDialog( + imageUrl: streamInfo + .thumbnailUrl, // Pass original URL with {width}x{height} placeholder + cacheKey: cacheKey, + ), + ), child: thumbnail, ), Container( @@ -119,7 +111,11 @@ class StreamCard extends StatelessWidget { style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, - color: context.frostyColors.overlayOnSurface, + color: context + .watch() + .dark + .colorScheme + .onSurface, ), ), ), @@ -172,7 +168,6 @@ class StreamCard extends StatelessWidget { ? () => Navigator.push( context, MaterialPageRoute( - settings: const RouteSettings(name: CategoryStreams.routeName), builder: (context) => CategoryStreams(categoryId: streamInfo.gameId), ), @@ -208,7 +203,6 @@ class StreamCard extends StatelessWidget { onTap: () => Navigator.push( context, MaterialPageRoute( - settings: const RouteSettings(name: VideoChat.routeName), builder: (context) => VideoChat( userId: streamInfo.userId, userName: streamInfo.userName, @@ -228,8 +222,6 @@ class StreamCard extends StatelessWidget { userId: streamInfo.userId, showPinOption: showPinOption, isPinned: isPinned, - initialIsFollowing: initialIsFollowing, - onFollowChanged: onFollowChanged, ), ); }, diff --git a/lib/screens/home/stream_list/stream_list_store.dart b/lib/screens/home/stream_list/stream_list_store.dart index f7165dd4..94efe2f1 100644 --- a/lib/screens/home/stream_list/stream_list_store.dart +++ b/lib/screens/home/stream_list/stream_list_store.dart @@ -3,7 +3,6 @@ import 'dart:io'; import 'package:flutter/widgets.dart'; import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/apis/twitch_api.dart'; -import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/models/category.dart'; import 'package:frosty/models/followed_channel.dart'; import 'package:frosty/models/stream.dart'; @@ -24,41 +23,19 @@ abstract class ListStoreBase with Store { /// Twitch API service class for making requests. final TwitchApi twitchApi; - /// GQL client for tag streams and ascending viewer sort (Helix has neither). - final TwitchGqlApi? twitchGqlApi; - /// The type of list that this store is handling. final ListType listType; /// The category id to use when fetching streams if the [listType] is [ListType.category]. final String? categoryId; - /// Freeform tag name when [listType] is [ListType.tag]. - final String? freeformTag; - - /// Viewer sort for top / category / tag lists. - @observable - StreamViewerSort sortOrder; - /// The scroll controller used for handling scroll to top (if provided). /// If provided, will use the [ScrollToTop] widget to scroll to the top of the list instead of the bottom tab bar. final ScrollController? scrollController; - /// When true, [dispose] will dispose [scrollController]. Shared controllers - /// from Home must not be disposed here. - final bool ownsScrollController; - /// The pagination cursor for the streams. String? _streamsCursor; - /// When true, pages come from Helix (GQL cursor/integrity unavailable). - /// ASC Helix fill starts from the top; duplicates are skipped and the list - /// is re-sorted client-side. - var _helixFill = false; - - /// Last page was served by GQL with server-side sort — append in API order. - var _lastPageWasGql = false; - /// The pagination cursor for the offline followed channels. String? _offlineChannelsCursor; @@ -67,8 +44,7 @@ abstract class ListStoreBase with Store { /// Returns whether or not there are more streams and loading status for pagination. @computed - bool get hasMore => - isLoading == false && (_streamsCursor != null || _helixFill); + bool get hasMore => isLoading == false && _streamsCursor != null; @computed bool get isLoading => @@ -77,12 +53,6 @@ abstract class ListStoreBase with Store { _isCategoryDetailsLoading || _isOfflineChannelsLoading; - /// Selected stream language codes from settings (lowercase ISO 639-1). - List get _languageFilter => settingsStore.streamLanguages - .map((l) => l.trim().toLowerCase()) - .where((l) => l.isNotEmpty) - .toList(); - /// The loading status for pagination. /// The list of the fetched streams. @@ -175,22 +145,24 @@ abstract class ListStoreBase with Store { ReactionDisposer? _pinnedStreamsReactionDisposer; ReactionDisposer? _offlineChannelsExpansionDisposer; - ReactionDisposer? _languageFilterReactionDisposer; ListStoreBase({ required this.authStore, required this.settingsStore, required this.twitchApi, required this.listType, - this.twitchGqlApi, this.categoryId, - this.freeformTag, - this.sortOrder = StreamViewerSort.viewersDesc, this.scrollController, - this.ownsScrollController = false, }) { if (scrollController != null) { - scrollController!.addListener(_onScroll); + scrollController!.addListener(() { + if (scrollController!.position.atEdge || + scrollController!.position.outOfRange) { + showJumpButton = false; + } else { + showJumpButton = true; + } + }); } if (listType == ListType.followed) { @@ -223,241 +195,54 @@ abstract class ListStoreBase with Store { _getCategoryDetails(); } - // Reload directory lists when the language filter or sort changes. - if (listType != ListType.followed) { - sortOrder = settingsStore.streamViewerSort; - _languageFilterReactionDisposer = reaction( - (_) => [ - ...settingsStore.streamLanguages, - settingsStore.streamViewerSort, - ], - (_) { - sortOrder = settingsStore.streamViewerSort; - refreshStreams(); - }, - ); - } - getStreams(); } - void _onScroll() { - final controller = scrollController; - if (controller == null || !controller.hasClients) return; - - final position = controller.position; - if (position.atEdge || position.outOfRange) { - showJumpButton = false; - } else { - showJumpButton = true; - } - - // Backup pagination trigger (in addition to SliverList near-end builds). - if (hasMore && - position.pixels >= position.maxScrollExtent - 800) { - getStreams(); - } - } - - /// Prefer GQL for tag directories (both sorts) and for ascending Top/category. - bool get _preferGqlDirectory { - if (twitchGqlApi == null || _helixFill) return false; - if (listType == ListType.tag) return true; - return listType != ListType.followed && - sortOrder == StreamViewerSort.viewersAsc; - } - /// Fetches the streams based on the type and current cursor. @action Future getStreams() async { - if (_isAllStreamsLoading) return; - _isAllStreamsLoading = true; try { - final languages = _languageFilter; - final isAsc = sortOrder == StreamViewerSort.viewersAsc; - final useGql = _preferGqlDirectory; - _lastPageWasGql = false; - - StreamsTwitch? newStreams; - if (listType == ListType.followed) { - newStreams = await twitchApi.getFollowedStreams( - id: authStore.user.details!.id, - cursor: _streamsCursor, - ); - } else if (useGql) { - newStreams = await _fetchGqlDirectory(languages: languages); - if (newStreams != null) { - _lastPageWasGql = true; - } + final StreamsTwitch newStreams; + switch (listType) { + case ListType.followed: + newStreams = await twitchApi.getFollowedStreams( + id: authStore.user.details!.id, + cursor: _streamsCursor, + ); + break; + case ListType.top: + newStreams = await twitchApi.getTopStreams(cursor: _streamsCursor); + break; + case ListType.category: + newStreams = await twitchApi.getStreamsUnderCategory( + gameId: categoryId!, + cursor: _streamsCursor, + ); + break; } - // Helix: Top/category DESC, or GQL unavailable / integrity fallback. - newStreams ??= await twitchApi.getDirectoryStreams( - languages: languages, - gameId: listType == ListType.category ? categoryId : null, - freeformTag: listType == ListType.tag ? freeformTag : null, - cursor: _streamsCursor, - minCount: listType == ListType.tag ? 40 : (isAsc ? 30 : 50), - maxPages: listType == ListType.tag ? 25 : (isAsc ? 20 : 12), - ); - - final next = newStreams.pagination['cursor']; - final hasCursor = next != null && next.isNotEmpty; - - // Empty Helix window with cursor left ⇒ keep scanning (sparse tags). - final isHelixContinuation = !_lastPageWasGql && - (_streamsCursor != null || - (_helixFill && _allStreams.isNotEmpty)); - if (isHelixContinuation && newStreams.data.isEmpty) { - if (hasCursor) { - _streamsCursor = next; - _error = null; - _isAllStreamsLoading = false; - await getStreams(); - return; - } - _streamsCursor = null; - _helixFill = false; - _error = null; - return; - } - - // GQL pages are already server-sorted — keep API order. - // Helix ASC fill: sort page + full list so late matches land correctly. - final pageData = (!_lastPageWasGql && isAsc) - ? (List.of(newStreams.data)..sort( - (a, b) => a.viewerCount.compareTo(b.viewerCount), - )) - : newStreams.data; - - if (_allStreams.isEmpty && !_helixFill) { - _allStreams = pageData.asObservable(); - } else { - final existingIds = _allStreams.map((s) => s.userId).toSet(); - _allStreams.addAll( - pageData.where((s) => !existingIds.contains(s.userId)), - ); - } - - if (!_lastPageWasGql && isAsc && _allStreams.length > 1) { - final sorted = _allStreams.toList() - ..sort((a, b) => a.viewerCount.compareTo(b.viewerCount)); - _allStreams = sorted.asObservable(); - } - - _streamsCursor = hasCursor ? next : null; if (_streamsCursor == null) { - _helixFill = false; - } else if (!_lastPageWasGql && isAsc) { - _helixFill = true; + _allStreams = newStreams.data.asObservable(); + } else { + _allStreams.addAll(newStreams.data); } + _streamsCursor = newStreams.pagination['cursor']; _error = null; } on SocketException { _error = 'Unable to connect to Twitch'; debugPrint('Streams SocketException: No internet connection'); - if (_streamsCursor != null && _allStreams.isNotEmpty) { - _streamsCursor = null; - _helixFill = false; - } } on ApiException catch (e) { _error = e.message; debugPrint('Streams ApiException: $e'); - if (_allStreams.isNotEmpty) { - // Mid-scroll GQL failure: fall back to Helix fill when useful. - if (!_helixFill && - (listType == ListType.tag || isAscDirectory)) { - _helixFill = true; - _streamsCursor = null; - _error = null; - _isAllStreamsLoading = false; - await getStreams(); - return; - } - _streamsCursor = null; - _helixFill = false; - _error = null; - } } catch (e) { _error = 'Something went wrong loading streams'; debugPrint('Streams error: $e'); - if (_streamsCursor != null && _allStreams.isNotEmpty) { - _streamsCursor = null; - _helixFill = false; - _error = null; - } - } finally { - _isAllStreamsLoading = false; } - } - /// GQL directory page with integrity retry. Returns null to trigger Helix. - Future _fetchGqlDirectory({ - required List languages, - }) async { - final gql = twitchGqlApi; - if (gql == null) return null; - - final langs = languages.isEmpty ? null : languages; - - try { - return await authStore.user.runWithGqlIntegrity( - gqlToken: authStore.gqlToken, - action: ({authToken, integrityToken, deviceId}) { - switch (listType) { - case ListType.tag: - return gql.getStreams( - freeformTags: [freeformTag!], - sort: sortOrder, - languages: langs, - cursor: _streamsCursor, - authToken: authToken, - integrityToken: integrityToken, - deviceId: deviceId, - ); - case ListType.category: - return gql.getGameStreams( - gameId: categoryId!, - sort: sortOrder, - languages: langs, - cursor: _streamsCursor, - authToken: authToken, - integrityToken: integrityToken, - deviceId: deviceId, - ); - case ListType.top: - return gql.getStreams( - sort: sortOrder, - languages: langs, - cursor: _streamsCursor, - authToken: authToken, - integrityToken: integrityToken, - deviceId: deviceId, - ); - case ListType.followed: - throw StateError('GQL directory not used for followed'); - } - }, - ); - } on IntegrityCheckException catch (e) { - debugPrint('Streams GQL integrity: $e — falling back to Helix'); - _helixFill = true; - // Keep cursor null so Helix starts from the top (dedupe handles overlap). - _streamsCursor = null; - return null; - } - } - - bool get isAscDirectory => - sortOrder == StreamViewerSort.viewersAsc && - listType != ListType.followed; - - /// Persists viewer sort and reloads via the settings reaction. - @action - Future setSortOrder(StreamViewerSort order) async { - settingsStore.streamViewerSort = order; + _isAllStreamsLoading = false; } @action @@ -535,8 +320,6 @@ abstract class ListStoreBase with Store { } _streamsCursor = null; - _helixFill = false; - _lastPageWasGql = false; await getStreams(); } @@ -566,14 +349,8 @@ abstract class ListStoreBase with Store { void dispose() { _pinnedStreamsReactionDisposer?.call(); _offlineChannelsExpansionDisposer?.call(); - _languageFilterReactionDisposer?.call(); - if (scrollController != null) { - scrollController!.removeListener(_onScroll); - if (ownsScrollController) { - scrollController!.dispose(); - } - } + scrollController?.dispose(); } } @@ -582,5 +359,4 @@ abstract class ListStoreBase with Store { /// [ListType.followed] is the list of streams that the user is following. /// [ListType.top] is the list of top streams. /// [ListType.category] is the list of streams under a category. -/// [ListType.tag] is streams matching a freeform tag (GQL sort + tags; Helix fallback). -enum ListType { followed, top, category, tag } +enum ListType { followed, top, category } diff --git a/lib/screens/home/stream_list/stream_list_store.g.dart b/lib/screens/home/stream_list/stream_list_store.g.dart index 3ae70481..7fa78463 100644 --- a/lib/screens/home/stream_list/stream_list_store.g.dart +++ b/lib/screens/home/stream_list/stream_list_store.g.dart @@ -56,24 +56,6 @@ mixin _$ListStore on ListStoreBase, Store { name: 'ListStoreBase.offlineChannels', )).value; - late final _$sortOrderAtom = Atom( - name: 'ListStoreBase.sortOrder', - context: context, - ); - - @override - StreamViewerSort get sortOrder { - _$sortOrderAtom.reportRead(); - return super.sortOrder; - } - - @override - set sortOrder(StreamViewerSort value) { - _$sortOrderAtom.reportWrite(value, super.sortOrder, () { - super.sortOrder = value; - }); - } - late final _$_allStreamsAtom = Atom( name: 'ListStoreBase._allStreams', context: context, @@ -320,16 +302,6 @@ mixin _$ListStore on ListStoreBase, Store { return _$getStreamsAsyncAction.run(() => super.getStreams()); } - late final _$setSortOrderAsyncAction = AsyncAction( - 'ListStoreBase.setSortOrder', - context: context, - ); - - @override - Future setSortOrder(StreamViewerSort order) { - return _$setSortOrderAsyncAction.run(() => super.setSortOrder(order)); - } - late final _$getPinnedStreamsAsyncAction = AsyncAction( 'ListStoreBase.getPinnedStreams', context: context, @@ -377,7 +349,6 @@ mixin _$ListStore on ListStoreBase, Store { @override String toString() { return ''' -sortOrder: ${sortOrder}, showJumpButton: ${showJumpButton}, isOfflineChannelsExpanded: ${isOfflineChannelsExpanded}, hasMore: ${hasMore}, diff --git a/lib/screens/home/stream_list/streams_list.dart b/lib/screens/home/stream_list/streams_list.dart index 46c1ffc1..ac97721d 100644 --- a/lib/screens/home/stream_list/streams_list.dart +++ b/lib/screens/home/stream_list/streams_list.dart @@ -2,7 +2,6 @@ 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/apis/twitch_gql_api.dart'; import 'package:frosty/models/stream.dart'; import 'package:frosty/screens/home/stream_list/large_stream_card.dart'; import 'package:frosty/screens/home/stream_list/offline_channel_card.dart'; @@ -26,12 +25,6 @@ class StreamsList extends StatefulWidget { final String? categoryId; - /// Freeform tag when [listType] is [ListType.tag]. - final String? freeformTag; - - /// Viewer sort for top / category / tag lists. - final StreamViewerSort sortOrder; - /// The scroll controller to use for scroll to top functionality. final ScrollController? scrollController; @@ -41,8 +34,6 @@ class StreamsList extends StatefulWidget { super.key, required this.listType, this.categoryId, - this.freeformTag, - this.sortOrder = StreamViewerSort.viewersDesc, this.scrollController, this.showJumpButton = false, }); @@ -72,24 +63,16 @@ class _StreamsListState extends State authStore: context.read(), settingsStore: context.read(), twitchApi: context.read(), - twitchGqlApi: context.read(), listType: widget.listType, categoryId: widget.categoryId, - freeformTag: widget.freeformTag, - sortOrder: widget.sortOrder, scrollController: widget.scrollController ?? _scrollController, - ownsScrollController: widget.scrollController == null && widget.showJumpButton, ); } @override void dispose() { - // ListStore disposes the controller when it owns it. - if (!_listStore.ownsScrollController) { - _scrollController?.dispose(); - } + _scrollController?.dispose(); WidgetsBinding.instance.removeObserver(this); - _listStore.dispose(); super.dispose(); } @@ -112,8 +95,6 @@ class _StreamsListState extends State ? kToolbarHeight : widget.listType == ListType.category ? kToolbarHeight + 122 - : widget.listType == ListType.tag - ? kToolbarHeight : 0.0; final topPadding = MediaQuery.of(context).padding.top + extraTopPadding; @@ -228,12 +209,9 @@ class _StreamsListState extends State ); } else { statusWidget = AlertMessage( - message: switch (widget.listType) { - ListType.followed => 'No followed streams', - ListType.tag => 'No streams with this tag', - ListType.category => 'No streams in this category', - ListType.top => 'No top streams', - }, + message: widget.listType == ListType.followed + ? 'No followed streams' + : 'No top streams', vertical: true, ); } @@ -260,12 +238,6 @@ class _StreamsListState extends State : _listStore.streams; final isFollowingTab = widget.listType == ListType.followed; - // Refresh following lists after unfollow so live/offline rows drop. - void onFollowChanged(bool following) { - if (isFollowingTab && !following) { - _listStore.refreshStreams(); - } - } return Stack( alignment: AlignmentDirectional.bottomCenter, @@ -326,9 +298,6 @@ class _StreamsListState extends State widget.categoryId == null, showPinOption: true, isPinned: true, - initialIsFollowing: true, - onFollowChanged: - onFollowChanged, ) : StreamCard( key: ValueKey(stream.userId), @@ -340,9 +309,6 @@ class _StreamsListState extends State widget.categoryId == null, showPinOption: true, isPinned: true, - initialIsFollowing: true, - onFollowChanged: - onFollowChanged, ); }, ); @@ -385,9 +351,6 @@ class _StreamsListState extends State showCategory: widget.categoryId == null, showPinOption: isFollowingTab, isPinned: false, - initialIsFollowing: - isFollowingTab ? true : null, - onFollowChanged: onFollowChanged, ) : StreamCard( key: ValueKey( @@ -399,9 +362,6 @@ class _StreamsListState extends State showCategory: widget.categoryId == null, showPinOption: isFollowingTab, isPinned: false, - initialIsFollowing: - isFollowingTab ? true : null, - onFollowChanged: onFollowChanged, ), ); }, @@ -450,8 +410,6 @@ class _StreamsListState extends State _listStore.offlineChannels[index], showPinOption: false, isPinned: false, - initialIsFollowing: true, - onFollowChanged: onFollowChanged, ), ); }, diff --git a/lib/screens/home/top/categories/category_card.dart b/lib/screens/home/top/categories/category_card.dart index 88daa502..91b71d6c 100644 --- a/lib/screens/home/top/categories/category_card.dart +++ b/lib/screens/home/top/categories/category_card.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:frosty/constants.dart'; import 'package:frosty/models/category.dart'; import 'package:frosty/screens/home/top/categories/category_streams.dart'; import 'package:frosty/widgets/frosty_cached_network_image.dart'; @@ -18,7 +17,7 @@ class CategoryCard extends StatelessWidget { @override Widget build(BuildContext context) { - // Calculate the dimensions of the box art based on the current dimensions of the screen. + // Calculate the dimmensions of the box art based on the current dimmensions of the screen. final size = MediaQuery.of(context).size; final pixelRatio = MediaQuery.of(context).devicePixelRatio; final artWidth = (size.width * pixelRatio) ~/ 5; @@ -29,7 +28,6 @@ class CategoryCard extends StatelessWidget { ? () => Navigator.push( context, MaterialPageRoute( - settings: const RouteSettings(name: CategoryStreams.routeName), builder: (context) => CategoryStreams(categoryId: category.id), ), ) @@ -47,7 +45,7 @@ class CategoryCard extends StatelessWidget { SizedBox( width: 80, child: ClipRRect( - borderRadius: kCardBorderRadius, + borderRadius: const BorderRadius.all(Radius.circular(8)), child: AspectRatio( aspectRatio: 3 / 4, child: FrostyCachedNetworkImage( @@ -57,7 +55,7 @@ class CategoryCard extends StatelessWidget { '${artWidth}x$artHeight.jpg', ), placeholder: (context, url) => const SkeletonLoader( - borderRadius: kCardBorderRadius, + borderRadius: BorderRadius.all(Radius.circular(8)), ), ), ), diff --git a/lib/screens/home/top/categories/category_streams.dart b/lib/screens/home/top/categories/category_streams.dart index d5a1edf0..adc85c6f 100644 --- a/lib/screens/home/top/categories/category_streams.dart +++ b/lib/screens/home/top/categories/category_streams.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/apis/twitch_api.dart'; -import 'package:frosty/apis/twitch_gql_api.dart'; -import 'package:frosty/constants.dart'; import 'package:frosty/screens/home/stream_list/stream_list_store.dart'; import 'package:frosty/screens/home/stream_list/streams_list.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; @@ -10,13 +8,10 @@ import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/widgets/blurred_container.dart'; import 'package:frosty/widgets/frosty_cached_network_image.dart'; import 'package:frosty/widgets/skeleton_loader.dart'; -import 'package:frosty/widgets/stream_sort_button.dart'; import 'package:provider/provider.dart'; /// A widget that displays a list of streams under the provided [categoryId]. class CategoryStreams extends StatefulWidget { - static const routeName = 'CategoryStreams'; - /// The category id, used for fetching the relevant streams in the [ListStore]. final String categoryId; @@ -27,16 +22,12 @@ class CategoryStreams extends StatefulWidget { } class _CategoryStreamsState extends State { - late final _settingsStore = context.read(); - late final _listStore = ListStore( listType: ListType.category, categoryId: widget.categoryId, authStore: context.read(), twitchApi: context.read(), - twitchGqlApi: context.read(), - settingsStore: _settingsStore, - sortOrder: _settingsStore.streamViewerSort, + settingsStore: context.read(), ); @override @@ -51,13 +42,10 @@ class _CategoryStreamsState extends State { children: [ // Stream list content behind the pinned elements Positioned.fill( - child: Observer( - builder: (_) => StreamsList( - listType: ListType.category, - categoryId: widget.categoryId, - sortOrder: _settingsStore.streamViewerSort, - showJumpButton: true, - ), + child: StreamsList( + listType: ListType.category, + categoryId: widget.categoryId, + showJumpButton: true, ), ), // Single blurred background spanning app bar and category card @@ -72,82 +60,89 @@ class _CategoryStreamsState extends State { left: MediaQuery.of(context).padding.left, right: MediaQuery.of(context).padding.right, ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // App bar section - SizedBox( - height: kToolbarHeight, - child: Row( - children: [ - IconButton( - tooltip: 'Back', - icon: Icon(Icons.adaptive.arrow_back_rounded), - onPressed: Navigator.of(context).pop, - ), - const Spacer(), - Observer( - builder: (_) => StreamSortButton( - sortOrder: _settingsStore.streamViewerSort, - onChanged: (order) => - _settingsStore.streamViewerSort = order, - ), - ), - ], + child: Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: theme.colorScheme.outlineVariant.withValues( + alpha: 0.3, + ), + width: 0.5, ), ), - // Category card section - Observer( - builder: (_) { - if (_listStore.categoryDetails != null) { - return _TransparentCategoryCard( - category: _listStore.categoryDetails!, - ); - } else { - // Skeleton loader for category card - return Padding( - padding: const EdgeInsets.only( - bottom: 16, - left: 16, - right: 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // App bar section + SizedBox( + height: kToolbarHeight, + child: Row( + children: [ + IconButton( + tooltip: 'Back', + icon: Icon(Icons.adaptive.arrow_back_rounded), + onPressed: Navigator.of(context).pop, ), - child: Row( - spacing: 12, - children: [ - const SizedBox( - width: 80, - child: AspectRatio( - aspectRatio: 3 / 4, - child: SkeletonLoader( - borderRadius: kCardBorderRadius, + ], + ), + ), + // Category card section + Observer( + builder: (_) { + if (_listStore.categoryDetails != null) { + return _TransparentCategoryCard( + category: _listStore.categoryDetails!, + ); + } else { + // Skeleton loader for category card + return Padding( + padding: const EdgeInsets.only( + bottom: 16, + left: 16, + right: 16, + ), + child: Row( + spacing: 12, + children: [ + const SizedBox( + width: 80, + child: AspectRatio( + aspectRatio: 3 / 4, + child: SkeletonLoader( + borderRadius: BorderRadius.all( + Radius.circular(8), + ), + ), ), ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 8, - children: [ - SkeletonLoader( - height: 20, - width: double.infinity, - borderRadius: BorderRadius.circular(4), - ), - SkeletonLoader( - height: 16, - width: 120, - borderRadius: BorderRadius.circular(4), - ), - ], + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + spacing: 8, + children: [ + SkeletonLoader( + height: 20, + width: double.infinity, + borderRadius: BorderRadius.circular(4), + ), + SkeletonLoader( + height: 16, + width: 120, + borderRadius: BorderRadius.circular(4), + ), + ], + ), ), - ), - ], - ), - ); - } - }, - ), - ], + ], + ), + ); + } + }, + ), + ], + ), ), ), ), @@ -185,7 +180,7 @@ class _TransparentCategoryCard extends StatelessWidget { SizedBox( width: 80, child: ClipRRect( - borderRadius: kCardBorderRadius, + borderRadius: const BorderRadius.all(Radius.circular(8)), child: AspectRatio( aspectRatio: 3 / 4, child: FrostyCachedNetworkImage( @@ -195,7 +190,7 @@ class _TransparentCategoryCard extends StatelessWidget { '${artWidth}x$artHeight.jpg', ), placeholder: (context, url) => const SkeletonLoader( - borderRadius: kCardBorderRadius, + borderRadius: BorderRadius.all(Radius.circular(8)), ), fit: BoxFit.cover, ), diff --git a/lib/screens/home/top/top.dart b/lib/screens/home/top/top.dart index 5f4d51a1..8fb8c9a5 100644 --- a/lib/screens/home/top/top.dart +++ b/lib/screens/home/top/top.dart @@ -1,13 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/home/home_store.dart'; import 'package:frosty/screens/home/stream_list/stream_list_store.dart'; import 'package:frosty/screens/home/stream_list/streams_list.dart'; import 'package:frosty/screens/home/top/categories/categories.dart'; -import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/widgets/blurred_container.dart'; -import 'package:frosty/widgets/stream_sort_button.dart'; -import 'package:provider/provider.dart'; class TopSection extends StatefulWidget { final HomeStore homeStore; @@ -25,30 +21,23 @@ class _TopSectionState extends State @override void initState() { super.initState(); - _tabBarController.addListener(() { - widget.homeStore.topSectionCurrentIndex = _tabBarController.index; - setState(() {}); - }); + _tabBarController.addListener( + () => widget.homeStore.topSectionCurrentIndex = _tabBarController.index, + ); } @override Widget build(BuildContext context) { - final settingsStore = context.watch(); - final showSort = _tabBarController.index == 0; - return Stack( children: [ Positioned.fill( child: TabBarView( controller: _tabBarController, children: [ - Observer( - builder: (_) => StreamsList( - listType: ListType.top, - sortOrder: settingsStore.streamViewerSort, - scrollController: - widget.homeStore.topSectionScrollControllers[0], - ), + StreamsList( + listType: ListType.top, + scrollController: + widget.homeStore.topSectionScrollControllers[0], ), Categories( scrollController: @@ -68,26 +57,12 @@ class _TopSectionState extends State left: MediaQuery.of(context).padding.left, right: MediaQuery.of(context).padding.right, ), - child: Row( - children: [ - Expanded( - child: TabBar( - isScrollable: true, - controller: _tabBarController, - tabs: const [ - Tab(child: Text('Streams')), - Tab(child: Text('Categories')), - ], - ), - ), - if (showSort) - Observer( - builder: (_) => StreamSortButton( - sortOrder: settingsStore.streamViewerSort, - onChanged: (order) => - settingsStore.streamViewerSort = order, - ), - ), + child: TabBar( + isScrollable: true, + controller: _tabBarController, + tabs: const [ + Tab(child: Text('Streams')), + Tab(child: Text('Categories')), ], ), ), diff --git a/lib/screens/onboarding/login_webview.dart b/lib/screens/onboarding/login_webview.dart index b631544d..360346be 100644 --- a/lib/screens/onboarding/login_webview.dart +++ b/lib/screens/onboarding/login_webview.dart @@ -1,39 +1,16 @@ import 'package:flutter/material.dart'; -import 'package:frosty/main.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/widgets/frosty_app_bar.dart'; import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:provider/provider.dart'; import 'package:webview_flutter/webview_flutter.dart'; -/// Pushes the atomic moderator-scopes upgrade WebView. -void openEnableModeratorTools(AuthStore authStore, {BuildContext? context}) { - final navContext = context ?? navigatorKey.currentContext; - if (navContext == null) return; - Navigator.of(navContext).push( - MaterialPageRoute( - builder: (context) => Provider.value( - value: authStore, - child: const LoginWebView(upgradeModeratorScopes: true), - ), - ), - ); -} - /// Reusable WebView widget for Twitch OAuth login flow class LoginWebView extends StatelessWidget { /// Optional widget to navigate to after successful login final Widget? routeAfter; - /// When true, requests core + moderator scopes and runs the atomic upgrade - /// commit instead of a full [AuthStore.login]. - final bool upgradeModeratorScopes; - - const LoginWebView({ - super.key, - this.routeAfter, - this.upgradeModeratorScopes = false, - }); + const LoginWebView({super.key, this.routeAfter}); @override Widget build(BuildContext context) { @@ -41,11 +18,7 @@ class LoginWebView extends StatelessWidget { return Scaffold( appBar: FrostyAppBar( - title: Text( - upgradeModeratorScopes - ? 'Enable moderator tools' - : 'Connect with Twitch', - ), + title: const Text('Connect with Twitch'), actions: [ IconButton( icon: const Icon(Icons.help_rounded), @@ -55,7 +28,7 @@ class LoginWebView extends StatelessWidget { return FrostyDialog( title: 'Workaround for the Twitch cookie banner', message: - 'If the Twitch cookie banner is still blocking the login, try clicking one of the links in the cookie policy description and navigating until you reach the Twitch home page. From there, try logging in from the top-right profile icon. Once logged in, go back to the first onboarding step and try again.', + 'If the Twitch cookie banner is still blocking the login, try clicking one of the links in the cookie policy description and navigating until you reach the Twitch home page. From there, you can try logging in on the top right profile icon. Once logged in, go back to the first step of the onboarding and then try again.', actions: [ TextButton( onPressed: Navigator.of(context).pop, @@ -71,7 +44,6 @@ class LoginWebView extends StatelessWidget { body: WebViewWidget( controller: authStore.createAuthWebViewController( routeAfter: routeAfter, - upgradeModeratorScopes: upgradeModeratorScopes, ), ), ); diff --git a/lib/screens/onboarding/onboarding_login.dart b/lib/screens/onboarding/onboarding_login.dart index f51de4ac..3cacfffc 100644 --- a/lib/screens/onboarding/onboarding_login.dart +++ b/lib/screens/onboarding/onboarding_login.dart @@ -12,7 +12,7 @@ class OnboardingLogin extends StatelessWidget { return OnboardingScaffold( header: 'Log in', subtitle: - 'Frosty needs permission to chat, view followed streams, and more.', + 'Frosty needs your permission in order to enable the ability to chat, view followed streams, and more.', disclaimer: 'Frosty only asks for the necessary permissions through the official Twitch API. You\'ll be able to review them before authorizing.', buttonText: 'Connect with Twitch', diff --git a/lib/screens/onboarding/onboarding_setup.dart b/lib/screens/onboarding/onboarding_setup.dart index 5a227f60..444b61e3 100644 --- a/lib/screens/onboarding/onboarding_setup.dart +++ b/lib/screens/onboarding/onboarding_setup.dart @@ -9,7 +9,6 @@ import 'package:frosty/screens/onboarding/onboarding_scaffold.dart'; import 'package:frosty/screens/onboarding/onboarding_welcome.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/screens/settings/widgets/settings_list_switch.dart'; -import 'package:frosty/utils/context_extensions.dart'; import 'package:frosty/widgets/accent_color_setting.dart'; import 'package:frosty/widgets/theme_selection_setting.dart'; import 'package:provider/provider.dart'; @@ -41,10 +40,10 @@ class OnboardingSetup extends StatelessWidget { children: [ TextSpan( text: 'https://recent-messages.robotty.de/', - style: TextStyle( - color: context.colorScheme.primary, + style: const TextStyle( + color: Colors.blue, decoration: TextDecoration.underline, - decorationColor: context.colorScheme.primary, + decorationColor: Colors.blue, ), recognizer: TapGestureRecognizer() ..onTap = () => launchUrl( diff --git a/lib/screens/settings/account/blocked_users.dart b/lib/screens/settings/account/blocked_users.dart index b8fa5ebf..28d72aac 100644 --- a/lib/screens/settings/account/blocked_users.dart +++ b/lib/screens/settings/account/blocked_users.dart @@ -42,9 +42,7 @@ class BlockedUsers extends StatelessWidget { return ListTile( title: Text(displayName), trailing: TextButton( - style: TextButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.error, - ), + style: TextButton.styleFrom(foregroundColor: Colors.red), onPressed: () => authStore.showBlockDialog( context, targetUser: displayName, diff --git a/lib/screens/settings/account/widgets/profile_card.dart b/lib/screens/settings/account/widgets/profile_card.dart index 36d024b2..7a55c3a5 100644 --- a/lib/screens/settings/account/widgets/profile_card.dart +++ b/lib/screens/settings/account/widgets/profile_card.dart @@ -5,7 +5,6 @@ import 'package:frosty/screens/onboarding/login_webview.dart'; import 'package:frosty/screens/settings/account/account_options.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/utils/modal_bottom_sheet.dart'; -import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:frosty/widgets/profile_picture.dart'; class ProfileCard extends StatelessWidget { @@ -40,78 +39,12 @@ class ProfileCard extends StatelessWidget { ); } if (authStore.isLoggedIn && authStore.user.details != null) { - final hasToken = authStore.gqlToken != null; return ListTile( leading: ProfilePicture( userLogin: authStore.user.details!.login, radius: 12, ), - title: Text.rich( - TextSpan( - text: authStore.user.details!.displayName, - children: [ - const WidgetSpan(child: SizedBox(width: 6)), - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () { - final navigator = Navigator.of(context); - showDialog( - context: context, - builder: (dialogContext) => FrostyDialog( - title: 'Web session', - message: hasToken - ? 'Your Twitch web session is linked. When using the native player, ads will be avoided on channels where you have a subscription or Twitch Turbo.' - : 'Your Twitch web session is not linked. Log in again to avoid ads when using the native player on channels where you have a subscription or Twitch Turbo.', - actions: hasToken - ? [ - TextButton( - onPressed: - Navigator.of(dialogContext).pop, - child: const Text('OK'), - ), - ] - : [ - TextButton( - onPressed: - Navigator.of(dialogContext).pop, - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - Navigator.of(dialogContext).pop(); - navigator.push( - MaterialPageRoute( - builder: (context) => - LoginWebView(), - ), - ); - }, - child: const Text('Log in'), - ), - ], - ), - ); - }, - // Padding enlarges the hit target without stealing taps - // on the display name / surrounding ListTile. - child: Padding( - padding: const EdgeInsets.all(4), - child: Icon( - hasToken - ? Icons.check_circle_outline_rounded - : Icons.info_outline_rounded, - size: 16, - color: hasToken ? Colors.green : Colors.amber, - ), - ), - ), - ), - ], - ), - overflow: TextOverflow.ellipsis, - ), + title: Text(authStore.user.details!.displayName), trailing: const Icon(Icons.chevron_right_rounded), onTap: () => _showAccountOptionsModalBottomSheet(context), ); @@ -120,7 +53,7 @@ class ProfileCard extends StatelessWidget { leading: const Icon(Icons.no_accounts_rounded), title: const Text('Anonymous'), subtitle: const Text( - 'Log in to chat, view followed streams, and more.', + 'Log in to enable the ability to chat, view followed streams, and more.', ), trailing: const SizedBox( height: double.infinity, diff --git a/lib/screens/settings/chat_settings.dart b/lib/screens/settings/chat_settings.dart index 2cd98029..9bc24f19 100644 --- a/lib/screens/settings/chat_settings.dart +++ b/lib/screens/settings/chat_settings.dart @@ -1,389 +1,347 @@ -import 'dart:io'; - -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:frosty/constants.dart'; -import 'package:frosty/screens/settings/stores/settings_store.dart'; -import 'package:frosty/screens/settings/widgets/settings_list_select.dart'; -import 'package:frosty/screens/settings/widgets/settings_list_slider.dart'; -import 'package:frosty/screens/settings/widgets/settings_list_switch.dart'; -import 'package:frosty/screens/settings/widgets/settings_muted_words.dart'; -import 'package:frosty/utils/context_extensions.dart'; -import 'package:frosty/widgets/frosty_cached_network_image.dart'; -import 'package:frosty/widgets/section_header.dart'; -import 'package:frosty/widgets/settings_page_layout.dart'; -import 'package:url_launcher/url_launcher.dart'; - -class ChatSettings extends StatelessWidget { - final SettingsStore settingsStore; - - const ChatSettings({super.key, required this.settingsStore}); - - @override - Widget build(BuildContext context) { - return Observer( - builder: (context) => SettingsPageLayout( - children: [ - const SectionHeader('General', isFirst: true), - SettingsListSwitch( - title: 'Keep screen on', - subtitle: const Text( - 'Prevents the screen from sleeping while a channel is open.', - ), - value: settingsStore.keepScreenAwake, - onChanged: (newValue) => - settingsStore.keepScreenAwake = newValue, - ), - SettingsListSwitch( - title: 'Autocomplete', - subtitle: const Text( - 'Shows matching emotes and mentions while typing.', - ), - value: settingsStore.autocomplete, - onChanged: (newValue) => - settingsStore.autocomplete = newValue, - ), - SettingsListSwitch( - title: 'Load recent messages', - subtitle: Text.rich( - TextSpan( - text: - 'Loads historical recent messages in chat through a third-party API service at ', - children: [ - TextSpan( - text: 'https://recent-messages.robotty.de/', - style: TextStyle( - color: context.colorScheme.primary, - decoration: TextDecoration.underline, - decorationColor: context.colorScheme.primary, - ), - recognizer: TapGestureRecognizer() - ..onTap = () => launchUrl( - Uri.parse( - 'https://recent-messages.robotty.de/', - ), - mode: settingsStore.launchUrlExternal - ? LaunchMode.externalApplication - : LaunchMode.inAppBrowserView, - ), - ), - ], - ), - ), - value: settingsStore.showRecentMessages, - onChanged: (newValue) => - settingsStore.showRecentMessages = newValue, - ), - const SectionHeader('Message sizing'), - ExpansionTile( - title: const Text('Preview'), - children: [ - Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - child: DefaultTextStyle( - style: DefaultTextStyle.of( - context, - ).style.copyWith(fontSize: settingsStore.fontSize), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text.rich( - TextSpan( - children: [ - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: FrostyCachedNetworkImage( - imageUrl: - 'https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/3', - height: - defaultBadgeSize * - settingsStore.badgeScale, - width: - defaultBadgeSize * - settingsStore.badgeScale, - ), - ), - const TextSpan( - text: ' Badge and emote preview. ', - ), - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: FrostyCachedNetworkImage( - imageUrl: - 'https://static-cdn.jtvnw.net/emoticons/v2/425618/default/dark/3.0', - height: - defaultEmoteSize * - settingsStore.emoteScale, - width: - defaultEmoteSize * - settingsStore.emoteScale, - ), - ), - ], - ), - textScaler: settingsStore.messageScale.textScaler, - ), - SizedBox(height: settingsStore.messageSpacing), - Text( - 'Hello! Here\'s a text preview.', - textScaler: settingsStore.messageScale.textScaler, - ), - SizedBox(height: settingsStore.messageSpacing), - Text( - 'And another for spacing without an emote!', - textScaler: settingsStore.messageScale.textScaler, - ), - ], - ), - ), - ), - ], - ), - SettingsListSlider( - title: 'Badge scale', - trailing: '${settingsStore.badgeScale.toStringAsFixed(2)}x', - value: settingsStore.badgeScale, - min: 0.25, - max: 3.0, - divisions: 11, - onChanged: (newValue) => settingsStore.badgeScale = newValue, - ), - SettingsListSlider( - title: 'Emote scale', - trailing: '${settingsStore.emoteScale.toStringAsFixed(2)}x', - value: settingsStore.emoteScale, - min: 0.25, - max: 3.0, - divisions: 11, - onChanged: (newValue) => settingsStore.emoteScale = newValue, - ), - SettingsListSlider( - title: 'Message scale', - trailing: '${settingsStore.messageScale.toStringAsFixed(2)}x', - value: settingsStore.messageScale, - min: 0.5, - max: 2.0, - divisions: 6, - onChanged: (newValue) => settingsStore.messageScale = newValue, - ), - SettingsListSlider( - title: 'Message spacing', - trailing: - '${settingsStore.messageSpacing.toStringAsFixed(0)}px', - value: settingsStore.messageSpacing, - max: 30.0, - divisions: 15, - onChanged: (newValue) => - settingsStore.messageSpacing = newValue, - ), - SettingsListSlider( - title: 'Font size', - trailing: settingsStore.fontSize.toInt().toString(), - value: settingsStore.fontSize, - min: 5, - max: 20, - divisions: 15, - onChanged: (newValue) => settingsStore.fontSize = newValue, - ), - const SectionHeader('Message appearance'), - SettingsListSwitch( - title: 'Show deleted messages', - subtitle: const Text( - 'Restores the original message of deleted messages.', - ), - value: settingsStore.showDeletedMessages, - onChanged: (newValue) => - settingsStore.showDeletedMessages = newValue, - ), - SettingsListSwitch( - title: 'Show message dividers', - value: settingsStore.showChatMessageDividers, - onChanged: (newValue) => - settingsStore.showChatMessageDividers = newValue, - ), - SettingsListSelect( - title: 'Timestamps', - selectedOption: - timestampNames[settingsStore.timestampType.index], - options: timestampNames, - onChanged: (newValue) => settingsStore.timestampType = - TimestampType.values[timestampNames.indexOf(newValue)], - ), - SettingsListSwitch( - title: 'Show timestamps on historical messages', - subtitle: const Text( - 'Always show timestamps on messages loaded from chat history, ' - 'even when timestamps are disabled above.', - ), - value: settingsStore.showHistoricalTimestamps, - onChanged: (newValue) => - settingsStore.showHistoricalTimestamps = newValue, - ), - SettingsListSwitch( - title: 'Focus current channel', - subtitle: const Text( - 'Fades messages from other channels in merged chat, ' - 'so the current channel stands out.', - ), - value: settingsStore.focusCurrentChannel, - onChanged: (newValue) => - settingsStore.focusCurrentChannel = newValue, - ), - const SectionHeader('Delay'), - SettingsListSwitch( - title: 'Auto-sync chat delay', - value: settingsStore.autoSyncChatDelay, - onChanged: (newValue) => - settingsStore.autoSyncChatDelay = newValue, - ), - if (!settingsStore.autoSyncChatDelay) - SettingsListSlider( - title: 'Chat delay', - trailing: '${settingsStore.chatDelay.toInt()} seconds', - subtitle: - 'Adds a delay before each message is rendered in chat. ${Platform.isIOS ? '15 seconds is recommended for iOS.' : ''}', - value: settingsStore.chatDelay, - max: 30.0, - divisions: 30, - onChanged: (newValue) => - settingsStore.chatDelay = newValue, - ), - const SectionHeader('Alerts'), - SettingsListSwitch( - title: 'Highlight first-time chatters', - value: settingsStore.highlightFirstTimeChatter, - onChanged: (newValue) => - settingsStore.highlightFirstTimeChatter = newValue, - ), - SettingsListSwitch( - title: 'Show notices', - subtitle: const Text( - 'Shows notices such as subs, announcements, and raids.', - ), - value: settingsStore.showUserNotices, - onChanged: (newValue) => - settingsStore.showUserNotices = newValue, - ), - const SectionHeader('Layout'), - SettingsListSwitch( - title: 'Emote menu on left', - subtitle: const Text( - 'Places the emote menu button on the left side.', - ), - value: settingsStore.emoteMenuButtonOnLeft, - onChanged: (newValue) => - settingsStore.emoteMenuButtonOnLeft = newValue, - ), - SettingsListSwitch( - title: 'Remember chat tabs', - subtitle: const Text( - 'Secondary chat tabs are kept when switching channels.', - ), - value: settingsStore.persistChatTabs, - onChanged: (newValue) { - settingsStore.persistChatTabs = newValue; - if (!newValue) { - settingsStore.secondaryTabs = []; - } - }, - ), - const SectionHeader('Landscape'), - SettingsListSwitch( - title: 'Chat on left side', - value: settingsStore.landscapeChatLeftSide, - onChanged: (newValue) => - settingsStore.landscapeChatLeftSide = newValue, - ), - SettingsListSwitch( - title: 'Force vertical chat', - subtitle: const Text( - 'Intended for tablets and larger displays.', - ), - value: settingsStore.landscapeForceVerticalChat, - onChanged: (newValue) => - settingsStore.landscapeForceVerticalChat = newValue, - ), - SettingsListSelect( - title: 'Notch fill', - subtitle: - 'Fills the display cutout area on the selected side.', - selectedOption: - landscapeCutoutNames[settingsStore.landscapeCutout.index], - options: landscapeCutoutNames, - onChanged: (newValue) => settingsStore.landscapeCutout = - LandscapeCutoutType.values[landscapeCutoutNames.indexOf( - newValue, - )], - ), - SettingsListSlider( - title: 'Overlay chat opacity', - trailing: - '${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%', - subtitle: - 'Opacity of the chat overlay in fullscreen mode.', - value: settingsStore.fullScreenChatOverlayOpacity, - divisions: 10, - onChanged: (newValue) => - settingsStore.fullScreenChatOverlayOpacity = newValue, - ), - const SectionHeader('Filtering'), - SettingsMutedWords(settingsStore: settingsStore), - SettingsListSwitch( - title: 'Match whole words', - subtitle: const Text( - 'Only matches whole words instead of partial matches.', - ), - value: settingsStore.matchWholeWord, - onChanged: (newValue) => - settingsStore.matchWholeWord = newValue, - ), - const SectionHeader('Emotes and badges'), - SettingsListSwitch( - title: 'Twitch emotes', - value: settingsStore.showTwitchEmotes, - onChanged: (newValue) => - settingsStore.showTwitchEmotes = newValue, - ), - SettingsListSwitch( - title: 'Twitch badges', - value: settingsStore.showTwitchBadges, - onChanged: (newValue) => - settingsStore.showTwitchBadges = newValue, - ), - SettingsListSwitch( - title: '7TV emotes', - value: settingsStore.show7TVEmotes, - onChanged: (newValue) => - settingsStore.show7TVEmotes = newValue, - ), - SettingsListSwitch( - title: 'BTTV emotes', - value: settingsStore.showBTTVEmotes, - onChanged: (newValue) => - settingsStore.showBTTVEmotes = newValue, - ), - SettingsListSwitch( - title: 'BTTV badges', - value: settingsStore.showBTTVBadges, - onChanged: (newValue) => - settingsStore.showBTTVBadges = newValue, - ), - SettingsListSwitch( - title: 'FFZ emotes', - value: settingsStore.showFFZEmotes, - onChanged: (newValue) => - settingsStore.showFFZEmotes = newValue, - ), - SettingsListSwitch( - title: 'FFZ badges', - value: settingsStore.showFFZBadges, - onChanged: (newValue) => - settingsStore.showFFZBadges = newValue, - ), - ], - ), - ); - } -} +import 'dart:io'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/constants.dart'; +import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:frosty/screens/settings/widgets/settings_list_select.dart'; +import 'package:frosty/screens/settings/widgets/settings_list_slider.dart'; +import 'package:frosty/screens/settings/widgets/settings_list_switch.dart'; +import 'package:frosty/screens/settings/widgets/settings_muted_words.dart'; +import 'package:frosty/utils/context_extensions.dart'; +import 'package:frosty/widgets/frosty_cached_network_image.dart'; +import 'package:frosty/widgets/section_header.dart'; +import 'package:frosty/widgets/settings_page_layout.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class ChatSettings extends StatefulWidget { + final SettingsStore settingsStore; + + const ChatSettings({super.key, required this.settingsStore}); + + @override + State createState() => _ChatSettingsState(); +} + +class _ChatSettingsState extends State { + var showPreview = false; + + @override + Widget build(BuildContext context) { + final settingsStore = widget.settingsStore; + + return Observer( + builder: (context) => SettingsPageLayout( + children: [ + const SectionHeader('Message sizing', isFirst: true), + ExpansionTile( + title: const Text('Preview'), + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + child: DefaultTextStyle( + style: DefaultTextStyle.of( + context, + ).style.copyWith(fontSize: settingsStore.fontSize), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text.rich( + TextSpan( + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: FrostyCachedNetworkImage( + imageUrl: + 'https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/3', + height: + defaultBadgeSize * settingsStore.badgeScale, + width: + defaultBadgeSize * settingsStore.badgeScale, + ), + ), + const TextSpan(text: ' Badge and emote preview. '), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: FrostyCachedNetworkImage( + imageUrl: + 'https://static-cdn.jtvnw.net/emoticons/v2/425618/default/dark/3.0', + height: + defaultEmoteSize * settingsStore.emoteScale, + width: + defaultEmoteSize * settingsStore.emoteScale, + ), + ), + ], + ), + textScaler: settingsStore.messageScale.textScaler, + ), + SizedBox(height: settingsStore.messageSpacing), + Text( + 'Hello! Here\'s a text preview.', + textScaler: settingsStore.messageScale.textScaler, + ), + SizedBox(height: settingsStore.messageSpacing), + Text( + 'And another for spacing without an emote!', + textScaler: settingsStore.messageScale.textScaler, + ), + ], + ), + ), + ), + ], + ), + SettingsListSlider( + title: 'Badge scale', + trailing: '${settingsStore.badgeScale.toStringAsFixed(2)}x', + value: settingsStore.badgeScale, + min: 0.25, + max: 3.0, + divisions: 11, + onChanged: (newValue) => settingsStore.badgeScale = newValue, + ), + SettingsListSlider( + title: 'Emote scale', + trailing: '${settingsStore.emoteScale.toStringAsFixed(2)}x', + value: settingsStore.emoteScale, + min: 0.25, + max: 3.0, + divisions: 11, + onChanged: (newValue) => settingsStore.emoteScale = newValue, + ), + SettingsListSlider( + title: 'Message scale', + trailing: '${settingsStore.messageScale.toStringAsFixed(2)}x', + value: settingsStore.messageScale, + min: 0.5, + max: 2.0, + divisions: 6, + onChanged: (newValue) => settingsStore.messageScale = newValue, + ), + SettingsListSlider( + title: 'Message spacing', + trailing: '${settingsStore.messageSpacing.toStringAsFixed(0)}px', + value: settingsStore.messageSpacing, + max: 30.0, + divisions: 15, + onChanged: (newValue) => settingsStore.messageSpacing = newValue, + ), + SettingsListSlider( + title: 'Font size', + trailing: settingsStore.fontSize.toInt().toString(), + value: settingsStore.fontSize, + min: 5, + max: 20, + divisions: 15, + onChanged: (newValue) => settingsStore.fontSize = newValue, + ), + const SectionHeader('Message appearance'), + SettingsListSwitch( + title: 'Show deleted messages', + subtitle: const Text( + 'Restores the original message of deleted messages.', + ), + value: settingsStore.showDeletedMessages, + onChanged: (newValue) => + settingsStore.showDeletedMessages = newValue, + ), + SettingsListSwitch( + title: 'Show message dividers', + value: settingsStore.showChatMessageDividers, + onChanged: (newValue) => + settingsStore.showChatMessageDividers = newValue, + ), + SettingsListSelect( + title: 'Message timestamps', + selectedOption: timestampNames[settingsStore.timestampType.index], + options: timestampNames, + onChanged: (newValue) => settingsStore.timestampType = + TimestampType.values[timestampNames.indexOf(newValue)], + ), + const SectionHeader('Delay and latency'), + SettingsListSwitch( + title: 'Sync message delay and stream latency', + value: settingsStore.autoSyncChatDelay, + onChanged: (newValue) => settingsStore.autoSyncChatDelay = newValue, + ), + if (!settingsStore.autoSyncChatDelay) + SettingsListSlider( + title: 'Message delay', + trailing: '${settingsStore.chatDelay.toInt()} seconds', + subtitle: + 'Adds a delay before each message is rendered in chat. ${Platform.isIOS ? '15 seconds is recommended for iOS.' : ''}', + value: settingsStore.chatDelay, + max: 30.0, + divisions: 30, + onChanged: (newValue) => settingsStore.chatDelay = newValue, + ), + const SectionHeader('Alerts'), + SettingsListSwitch( + title: 'Highlight first time chatters', + value: settingsStore.highlightFirstTimeChatter, + onChanged: (newValue) => + settingsStore.highlightFirstTimeChatter = newValue, + ), + SettingsListSwitch( + title: 'Show notices', + subtitle: const Text( + 'Shows notices such as subs and re-subs, announcements, and raids.', + ), + value: settingsStore.showUserNotices, + onChanged: (newValue) => settingsStore.showUserNotices = newValue, + ), + const SectionHeader('Layout'), + SettingsListSwitch( + title: 'Move emote menu button left', + subtitle: const Text( + 'Places the emote menu button on the left side to avoid accidental presses.', + ), + value: settingsStore.emoteMenuButtonOnLeft, + onChanged: (newValue) => + settingsStore.emoteMenuButtonOnLeft = newValue, + ), + SettingsListSwitch( + title: 'Persist chat tabs', + subtitle: const Text( + 'Secondary chat tabs are remembered when switching channels.', + ), + value: settingsStore.persistChatTabs, + onChanged: (newValue) { + settingsStore.persistChatTabs = newValue; + if (!newValue) { + settingsStore.secondaryTabs = []; + } + }, + ), + const SectionHeader('Landscape mode'), + SettingsListSwitch( + title: 'Move chat left', + value: settingsStore.landscapeChatLeftSide, + onChanged: (newValue) => + settingsStore.landscapeChatLeftSide = newValue, + ), + SettingsListSwitch( + title: 'Force vertical chat', + subtitle: const Text( + 'Intended for tablets and other larger displays.', + ), + value: settingsStore.landscapeForceVerticalChat, + onChanged: (newValue) => + settingsStore.landscapeForceVerticalChat = newValue, + ), + SettingsListSelect( + title: 'Fill notch side', + subtitle: + 'Overrides and fills the available space in devices with a display notch.', + selectedOption: + landscapeCutoutNames[settingsStore.landscapeCutout.index], + options: landscapeCutoutNames, + onChanged: (newValue) => settingsStore.landscapeCutout = + LandscapeCutoutType.values[landscapeCutoutNames.indexOf( + newValue, + )], + ), + SettingsListSlider( + title: 'Chat overlay opacity', + trailing: + '${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%', + subtitle: + 'Sets the opacity (transparency) of the overlay chat in fullscreen mode.', + value: settingsStore.fullScreenChatOverlayOpacity, + divisions: 10, + onChanged: (newValue) => + settingsStore.fullScreenChatOverlayOpacity = newValue, + ), + const SectionHeader('Muted keywords'), + SettingsMutedWords(settingsStore: settingsStore), + SettingsListSwitch( + title: 'Match whole words', + subtitle: const Text( + 'Only matches whole words instead of partial matches.', + ), + value: settingsStore.matchWholeWord, + onChanged: (newValue) => settingsStore.matchWholeWord = newValue, + ), + const SectionHeader('Autocomplete'), + SettingsListSwitch( + title: 'Show autocomplete bar', + subtitle: const Text( + 'Shows a bar containing matching emotes and mentions while typing.', + ), + value: settingsStore.autocomplete, + onChanged: (newValue) => settingsStore.autocomplete = newValue, + ), + const SectionHeader('Emotes and badges'), + SettingsListSwitch( + title: 'Show Twitch emotes', + value: settingsStore.showTwitchEmotes, + onChanged: (newValue) => settingsStore.showTwitchEmotes = newValue, + ), + SettingsListSwitch( + title: 'Show Twitch badges', + value: settingsStore.showTwitchBadges, + onChanged: (newValue) => settingsStore.showTwitchBadges = newValue, + ), + SettingsListSwitch( + title: 'Show 7TV emotes', + value: settingsStore.show7TVEmotes, + onChanged: (newValue) => settingsStore.show7TVEmotes = newValue, + ), + SettingsListSwitch( + title: 'Show BTTV emotes', + value: settingsStore.showBTTVEmotes, + onChanged: (newValue) => settingsStore.showBTTVEmotes = newValue, + ), + SettingsListSwitch( + title: 'Show BTTV badges', + value: settingsStore.showBTTVBadges, + onChanged: (newValue) => settingsStore.showBTTVBadges = newValue, + ), + SettingsListSwitch( + title: 'Show FFZ emotes', + value: settingsStore.showFFZEmotes, + onChanged: (newValue) => settingsStore.showFFZEmotes = newValue, + ), + SettingsListSwitch( + title: 'Show FFZ badges', + value: settingsStore.showFFZBadges, + onChanged: (newValue) => settingsStore.showFFZBadges = newValue, + ), + const SectionHeader('Recent messages'), + SettingsListSwitch( + title: 'Show historical recent messages', + subtitle: Text.rich( + TextSpan( + text: + 'Loads historical recent messages in chat through a third-party API service at ', + children: [ + TextSpan( + text: 'https://recent-messages.robotty.de/', + style: const TextStyle( + color: Colors.blue, + decoration: TextDecoration.underline, + decorationColor: Colors.blue, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => launchUrl( + Uri.parse('https://recent-messages.robotty.de/'), + mode: settingsStore.launchUrlExternal + ? LaunchMode.externalApplication + : LaunchMode.inAppBrowserView, + ), + ), + ], + ), + ), + value: settingsStore.showRecentMessages, + onChanged: (newValue) => + settingsStore.showRecentMessages = newValue, + ), + ], + ), + ); + } +} diff --git a/lib/screens/settings/general_settings.dart b/lib/screens/settings/general_settings.dart index 96d5f8c9..e67149f6 100644 --- a/lib/screens/settings/general_settings.dart +++ b/lib/screens/settings/general_settings.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; +// import removed: flutter_colorpicker import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:frosty/models/stream_languages.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/screens/settings/widgets/settings_list_switch.dart'; import 'package:frosty/widgets/accent_color_setting.dart'; import 'package:frosty/widgets/external_browser_setting.dart'; +// import removed: frosty/widgets/dialog.dart import 'package:frosty/widgets/section_header.dart'; import 'package:frosty/widgets/settings_page_layout.dart'; import 'package:frosty/widgets/theme_selection_setting.dart'; @@ -24,57 +25,15 @@ class GeneralSettings extends StatelessWidget { AccentColorSetting(settingsStore: settingsStore), const SectionHeader('Stream card'), SettingsListSwitch( - title: 'Large stream cards', + title: 'Use large stream card', value: settingsStore.largeStreamCard, onChanged: (newValue) => settingsStore.largeStreamCard = newValue, ), SettingsListSwitch( - title: 'Show thumbnails', + title: 'Show thumbnail', value: settingsStore.showThumbnails, onChanged: (newValue) => settingsStore.showThumbnails = newValue, ), - const SectionHeader('Stream languages'), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - child: Text( - settingsStore.streamLanguages.isEmpty - ? 'Showing all languages. Select one or more to filter Top, category, and tag lists (like Twitch).' - : 'Only streams in the selected languages appear in Top, category, and tag lists.', - style: Theme.of(context).textTheme.bodySmall, - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final entry in StreamLanguages.all) - FilterChip( - label: Text(entry.label), - selected: settingsStore.streamLanguages.contains(entry.code), - onSelected: (selected) { - final next = [...settingsStore.streamLanguages]; - if (selected) { - if (!next.contains(entry.code)) next.add(entry.code); - } else { - next.remove(entry.code); - } - settingsStore.streamLanguages = next; - }, - ), - ], - ), - ), - if (settingsStore.streamLanguages.isNotEmpty) - Align( - alignment: Alignment.centerLeft, - child: TextButton( - onPressed: () => - settingsStore.streamLanguages = const [], - child: const Text('Clear language filter'), - ), - ), const SectionHeader('Links'), ExternalBrowserSetting(settingsStore: settingsStore), ], diff --git a/lib/screens/settings/other_settings.dart b/lib/screens/settings/other_settings.dart index 0edda66f..d58f69df 100644 --- a/lib/screens/settings/other_settings.dart +++ b/lib/screens/settings/other_settings.dart @@ -4,12 +4,15 @@ import 'package:firebase_performance/firebase_performance.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:frosty/cache_manager.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/screens/settings/widgets/release_notes.dart'; import 'package:frosty/screens/settings/widgets/settings_list_switch.dart'; import 'package:frosty/widgets/alert_message.dart'; import 'package:frosty/widgets/frosty_dialog.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; class OtherSettings extends StatefulWidget { final SettingsStore settingsStore; @@ -60,21 +63,49 @@ class _OtherSettingsState extends State { Widget build(BuildContext context) { return Column( children: [ + ListTile( + leading: const Icon(Icons.info_outline_rounded), + title: const Text('About Frosty'), + onTap: () async { + final packageInfo = await PackageInfo.fromPlatform(); + + if (!context.mounted) return; + + showAboutDialog( + context: context, + applicationIcon: SvgPicture.asset( + 'assets/icons/logo.svg', + height: 80, + ), + applicationName: packageInfo.appName, + applicationVersion: + 'Version ${packageInfo.version} (${packageInfo.buildNumber})', + applicationLegalese: '\u{a9} 2026 Tommy Chow', + ); + }, + ), ListTile( leading: const Icon(Icons.notes_rounded), title: const Text('Release notes'), onTap: () => Navigator.of( context, - ).push(MaterialPageRoute( - settings: const RouteSettings(name: ReleaseNotes.routeName), - builder: (context) => const ReleaseNotes(), - )), + ).push(MaterialPageRoute(builder: (context) => const ReleaseNotes())), + ), + ListTile( + leading: const Icon(Icons.launch_rounded), + title: const Text('FAQ'), + onTap: () => launchUrl( + Uri.parse('https://www.frostyapp.io/#faq'), + mode: widget.settingsStore.launchUrlExternal + ? LaunchMode.externalApplication + : LaunchMode.inAppBrowserView, + ), ), ListTile( leading: const Icon(Icons.delete_outline_rounded), title: const Text('Clear image cache'), onTap: () async { - HapticFeedback.lightImpact(); + HapticFeedback.mediumImpact(); await CustomCacheManager.instance.emptyCache(); await CustomCacheManager.removeOrphanedCacheFiles(); diff --git a/lib/screens/settings/settings.dart b/lib/screens/settings/settings.dart index a0360635..90bfe9d5 100644 --- a/lib/screens/settings/settings.dart +++ b/lib/screens/settings/settings.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:frosty/screens/settings/account/widgets/profile_card.dart'; @@ -11,14 +13,11 @@ import 'package:frosty/screens/settings/widgets/settings_tile_route.dart'; import 'package:frosty/widgets/blurred_container.dart'; import 'package:frosty/widgets/frosty_scrollbar.dart'; import 'package:frosty/widgets/section_header.dart'; -import 'package:package_info_plus/package_info_plus.dart'; import 'package:provider/provider.dart'; import 'package:simple_icons/simple_icons.dart'; import 'package:url_launcher/url_launcher.dart'; class Settings extends StatelessWidget { - static const routeName = 'Settings'; - final SettingsStore settingsStore; const Settings({super.key, required this.settingsStore}); @@ -49,7 +48,8 @@ class Settings extends StatelessWidget { ), title: const Text('Settings'), actions: [ - IconButton( + if (Platform.isAndroid) + IconButton( tooltip: 'Support Frosty', onPressed: () => launchUrl( Uri.parse('https://www.buymeacoffee.com/tommychow'), @@ -77,134 +77,38 @@ class Settings extends StatelessWidget { body: Stack( children: [ // Main scrollable content - SafeArea( - top: false, - bottom: false, - child: FrostyScrollbar( + FrostyScrollbar( + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + kToolbarHeight, + ), + child: ListView( padding: EdgeInsets.only( - top: MediaQuery.of(context).padding.top + kToolbarHeight, + top: MediaQuery.of(context).padding.top + kToolbarHeight + 8, ), - child: CustomScrollView( - slivers: [ - SliverPadding( - padding: EdgeInsets.only( - top: MediaQuery.of(context).padding.top + - kToolbarHeight + - 8, - ), - sliver: SliverList.list( - children: [ - const SectionHeader('Account', isFirst: true), - ProfileCard(authStore: context.read()), - const SectionHeader('Customize'), - SettingsTileRoute( - leading: const Icon(Icons.settings_outlined), - title: 'General', - child: GeneralSettings(settingsStore: settingsStore), - ), - SettingsTileRoute( - leading: const Icon(Icons.tv_rounded), - title: 'Video', - child: VideoSettings(settingsStore: settingsStore), - ), - SettingsTileRoute( - leading: const Icon(Icons.chat_outlined), - title: 'Chat', - child: ChatSettings(settingsStore: settingsStore), - ), - const SectionHeader('Other'), - OtherSettings(settingsStore: settingsStore), - ], - ), + children: [ + const SectionHeader('Account', isFirst: true), + ProfileCard(authStore: context.read()), + const SectionHeader('Customize'), + SettingsTileRoute( + leading: const Icon(Icons.settings_outlined), + title: 'General', + child: GeneralSettings(settingsStore: settingsStore), ), - SliverFillRemaining( - hasScrollBody: false, - child: Align( - alignment: Alignment.bottomCenter, - child: FutureBuilder( - future: PackageInfo.fromPlatform(), - builder: (context, snapshot) { - final version = snapshot.data?.version ?? ''; - final buildNumber = snapshot.data?.buildNumber ?? ''; - final mutedColor = theme.colorScheme.onSurfaceVariant - .withValues(alpha: 0.5); - return Padding( - padding: EdgeInsets.only( - top: 16, - bottom: MediaQuery.of(context).padding.bottom, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text.rich( - TextSpan( - style: - theme.textTheme.bodySmall?.copyWith( - color: mutedColor, - ), - children: [ - const TextSpan(text: 'Made by '), - WidgetSpan( - alignment: - PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - child: GestureDetector( - onTap: () => launchUrl( - Uri.parse( - 'https://tommychow.com', - ), - mode: settingsStore - .launchUrlExternal - ? LaunchMode - .externalApplication - : LaunchMode - .inAppBrowserView, - ), - child: Text( - 'Tommy Chow', - style: theme.textTheme.bodySmall - ?.copyWith( - color: mutedColor, - decoration: - TextDecoration.underline, - decorationColor: mutedColor, - ), - ), - ), - ), - ], - ), - ), - const SizedBox(height: 4), - if (snapshot.hasData) - GestureDetector( - onTap: () => launchUrl( - Uri.parse( - 'https://github.com/tommyxchow/frosty/releases/tag/v$version', - ), - mode: settingsStore.launchUrlExternal - ? LaunchMode.externalApplication - : LaunchMode.inAppBrowserView, - ), - child: Text( - 'v$version ($buildNumber)', - style: - theme.textTheme.bodySmall?.copyWith( - color: mutedColor, - ), - ), - ), - ], - ), - ); - }, - ), - ), + SettingsTileRoute( + leading: const Icon(Icons.tv_rounded), + title: 'Video', + child: VideoSettings(settingsStore: settingsStore), ), + SettingsTileRoute( + leading: const Icon(Icons.chat_outlined), + title: 'Chat', + child: ChatSettings(settingsStore: settingsStore), + ), + const SectionHeader('Other'), + OtherSettings(settingsStore: settingsStore), ], ), ), - ), // Blurred app bar overlay Positioned( top: 0, diff --git a/lib/screens/settings/stores/auth_store.dart b/lib/screens/settings/stores/auth_store.dart index d4581732..19d66731 100644 --- a/lib/screens/settings/stores/auth_store.dart +++ b/lib/screens/settings/stores/auth_store.dart @@ -1,17 +1,13 @@ import 'dart:async'; import 'dart:io'; -import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/apis/twitch_api.dart'; -import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/main.dart'; import 'package:frosty/screens/settings/stores/user_store.dart'; -import 'package:frosty/services/cookie_extractor.dart'; -import 'package:frosty/utils/twitch_oauth_scopes.dart' as twitch_scopes; import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:mobx/mobx.dart'; import 'package:webview_flutter/webview_flutter.dart'; @@ -30,20 +26,11 @@ abstract class AuthBase with Store { /// The shared_preferences key for the user token. static const _userTokenKey = 'user_token'; - /// The secure storage key for the GQL web cookie token. - static const _gqlTokenKey = 'gql_token'; - - /// Twitch user id that opted into moderator scopes on this device. - static const _moderatorOptInUserIdKey = 'moderator_scopes_opt_in_user_id'; - - /// Last logged-in Twitch user id (kept across logout for re-login scope choice). - static const _lastUserIdKey = 'last_user_id'; - /// The Twitch API service for making requests. final TwitchApi twitchApi; - /// Web GQL client (follow/unfollow + playback tokens). - final TwitchGqlApi twitchGqlApi; + /// Whether the token is valid or not. + var _tokenIsValid = false; /// Timer used to retry authentication when offline or on transient failures. Timer? _reconnectTimer; @@ -55,35 +42,16 @@ abstract class AuthBase with Store { static const _maxReconnectAttempts = 5; /// The MobX store containing information relevant to the current user. - late final UserStore user; + final UserStore user; /// The user token used to authenticate with the Twitch API. @readonly String? _token; - /// Web cookie token extracted from the login WebView for GQL ad-free playback. - @readonly - String? _gqlToken; - /// Whether the user is logged in or not. @readonly var _isLoggedIn = false; - /// OAuth scopes granted to the current user token. - @readonly - var _grantedScopes = ObservableList(); - - /// Twitch user id that previously enabled moderator tools on this device. - String? _moderatorOptInUserId; - - /// Last known Twitch user id, retained after logout for scope selection. - String? _lastUserId; - - /// Whether the current token includes every moderator scope Frosty needs. - @computed - bool get hasModeratorScopes => - twitch_scopes.hasModeratorScopes(_grantedScopes); - /// Authentication headers for Twitch API requests. @computed Map get headersTwitch => { @@ -99,7 +67,6 @@ abstract class AuthBase with Store { FutureOr handleNavigation({ required NavigationRequest request, Widget? routeAfter, - bool upgradeModeratorScopes = false, }) { // Check if the URL is the redirect URI. if (request.url.startsWith('https://twitch.tv/login')) { @@ -107,27 +74,14 @@ abstract class AuthBase with Store { final uri = Uri.parse(request.url.replaceFirst('#', '?')); final token = uri.queryParameters['access_token']; - if (token != null) { - if (upgradeModeratorScopes) { - unawaited(completeModeratorUpgrade(token: token)); - } else { - unawaited(login(token: token)); - } - } + // Login with the provided token. + if (token != null) login(token: token); } - // Check if the URL has been redirected to "https://www.twitch.tv/?no-reload=true". + // Check if the the URL has been redirected to "https://www.twitch.tv/?no-reload=true". // When redirected to the redirect_uri, there will be another redirect to "https://www.twitch.tv/?no-reload=true". // Checking for this will ensure that the user has automatically logged in to Twitch on the WebView itself. if (request.url == 'https://www.twitch.tv/?no-reload=true') { - _extractGqlToken(); - - // Upgrade WebViews pop themselves after an atomic commit so we never - // dismiss into a half-applied session. - if (upgradeModeratorScopes) { - return NavigationDecision.navigate; - } - if (routeAfter != null) { navigatorKey.currentState?.pop(); navigatorKey.currentState?.push( @@ -143,10 +97,7 @@ abstract class AuthBase with Store { return NavigationDecision.navigate; } - WebViewController createAuthWebViewController({ - Widget? routeAfter, - bool upgradeModeratorScopes = false, - }) { + WebViewController createAuthWebViewController({Widget? routeAfter}) { final webViewController = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) // Use platform-specific user agents to allow Google OAuth sign-in. @@ -158,18 +109,11 @@ abstract class AuthBase with Store { : 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Mobile Safari/537.36', ); - final scopeQuery = upgradeModeratorScopes || _shouldIncludeModeratorScopes - ? twitch_scopes.twitchAllUserScopeQuery - : twitch_scopes.twitchUserScopeQuery; - return webViewController ..setNavigationDelegate( NavigationDelegate( - onNavigationRequest: (request) => handleNavigation( - request: request, - routeAfter: routeAfter, - upgradeModeratorScopes: upgradeModeratorScopes, - ), + onNavigationRequest: (request) => + handleNavigation(request: request, routeAfter: routeAfter), onWebResourceError: (error) { debugPrint('Auth WebView error: ${error.description}'); }, @@ -216,7 +160,8 @@ abstract class AuthBase with Store { 'client_id': clientId, 'redirect_uri': 'https://twitch.tv/login', 'response_type': 'token', - 'scope': scopeQuery, + 'scope': + 'chat:read chat:edit user:read:follows user:read:blocked_users user:manage:blocked_users user:manage:chat_color', 'force_verify': 'true', }, ), @@ -263,88 +208,14 @@ abstract class AuthBase with Store { ); } - AuthBase({ - required this.twitchApi, - required this.twitchGqlApi, - }) { - user = UserStore( - twitchApi: twitchApi, - twitchGqlApi: twitchGqlApi, - hasModeratorScopes: () => hasModeratorScopes, - ); - } - - bool get _shouldIncludeModeratorScopes { - final currentId = user.details?.id; - if (currentId != null && currentId == _moderatorOptInUserId) { - return true; - } - // After logout, still request mod scopes for the same opted-in account. - if (currentId == null && - _lastUserId != null && - _lastUserId == _moderatorOptInUserId) { - return true; - } - return false; - } - - Future _extractGqlToken() async { - try { - final token = await CookieExtractor.extractTwitchAuthToken(); - if (token != null) { - runInAction(() { - _gqlToken = token; - }); - await _storage.write(key: _gqlTokenKey, value: token); - } - } catch (e) { - debugPrint('GQL token extraction failed: $e'); - } - } - - /// Clears the stored web session token after Twitch rejects it, so the - /// profile card reflects the unlinked state and future stream loads skip - /// the doomed authenticated request. - @action - Future invalidateGqlToken() async { - _gqlToken = null; - await _storage.delete(key: _gqlTokenKey); - } - - void _setGrantedScopes(List scopes) { - _grantedScopes = ObservableList.of(scopes); - } - - Future _persistModeratorOptIn(String userId) async { - if (_moderatorOptInUserId == userId) return; - _moderatorOptInUserId = userId; - await _storage.write(key: _moderatorOptInUserIdKey, value: userId); - } - - Future _persistLastUserId(String? userId) async { - if (_lastUserId == userId) return; - _lastUserId = userId; - if (userId == null) { - await _storage.delete(key: _lastUserIdKey); - } else { - await _storage.write(key: _lastUserIdKey, value: userId); - } - } + AuthBase({required this.twitchApi}) : user = UserStore(twitchApi: twitchApi); /// Initialize by retrieving a token if it does not already exist. @action Future init() async { try { - final stored = await Future.wait([ - _storage.read(key: _moderatorOptInUserIdKey), - _storage.read(key: _lastUserIdKey), - _storage.read(key: _userTokenKey), - _storage.read(key: _gqlTokenKey), - ]); - _moderatorOptInUserId = stored[0]; - _lastUserId = stored[1]; - _token = stored[2]; - _gqlToken = stored[3]; + // Read and set the currently stored user token, if any. + _token = await _storage.read(key: _userTokenKey); // If the token does not exist, get the default token. // Otherwise, log in. @@ -352,44 +223,33 @@ abstract class AuthBase with Store { // Retrieve the currently stored default token if it exists. _token = await _storage.read(key: _defaultTokenKey); // If the token does not exist or is invalid, get a new token and store it. - if (_token == null || - await twitchApi.validateToken(token: _token!) == null) { + if (_token == null || !await twitchApi.validateToken(token: _token!)) { _token = await twitchApi.getDefaultToken(); await _storage.write(key: _defaultTokenKey, value: _token); } - _setGrantedScopes(const []); } else { // Validate the existing token. If it fails, start reconnect loop. - final TwitchTokenInfo info; try { - final validated = await twitchApi.validateToken(token: _token!); - if (validated == null) return await logout(); - info = validated; + _tokenIsValid = await twitchApi.validateToken(token: _token!); } on ApiException catch (e) { debugPrint('Token validation failed: $e'); _isLoggedIn = false; - _setGrantedScopes(const []); _startReconnectLoop(); return; } - _setGrantedScopes(info.scopes); + // If the token is invalid, logout. + if (!_tokenIsValid) return await logout(); // Initialize the user store. await user.init(); if (user.details != null) { _isLoggedIn = true; - await _persistLastUserId(user.details!.id); _stopReconnectLoop(); - - // Recover a missed web session link — the WebView cookie store may - // still hold a valid auth-token from a previous login. - if (_gqlToken == null) _extractGqlToken(); } } - FirebaseCrashlytics.instance.setCustomKey('is_logged_in', _isLoggedIn); _error = null; } catch (e) { debugPrint(e.toString()); @@ -402,9 +262,8 @@ abstract class AuthBase with Store { Future login({required String token}) async { try { // Validate the custom token. - final info = await twitchApi.validateToken(token: token); - if (info == null) return; - _setGrantedScopes(info.scopes); + _tokenIsValid = await twitchApi.validateToken(token: token); + if (!_tokenIsValid) return; // Replace the current default token with the new custom token. _token = token; @@ -418,14 +277,6 @@ abstract class AuthBase with Store { // Set the login status to logged in. if (user.details != null) { _isLoggedIn = true; - await _persistLastUserId(user.details!.id); - // A fresh login that already includes mod scopes (re-auth after opt-in) - // should keep the opt-in flag for this account. - if (hasModeratorScopes) { - await _persistModeratorOptIn(user.details!.id); - } - FirebaseCrashlytics.instance.setCustomKey('is_logged_in', true); - FirebaseCrashlytics.instance.setUserIdentifier(user.details!.id); _stopReconnectLoop(); } } catch (e) { @@ -433,96 +284,14 @@ abstract class AuthBase with Store { } } - /// Atomically upgrades the current session with moderator OAuth scopes. - /// - /// Keeps the existing token until validation proves the new token is for the - /// same user and includes every moderator scope. On any failure, the old - /// session is left untouched. - @action - Future completeModeratorUpgrade({required String token}) async { - final currentUserId = user.details?.id; - if (currentUserId == null || !_isLoggedIn) { - _showModeratorUpgradeFailed( - 'Log in first, then enable moderator tools from a channel you moderate.', - ); - return false; - } - - try { - final info = await twitchApi.validateToken(token: token); - if (info == null) { - _showModeratorUpgradeFailed( - 'Could not validate the new Twitch permissions. Your current session was kept.', - ); - return false; - } - - if (info.userId != currentUserId) { - _showModeratorUpgradeFailed( - 'Log in with the same Twitch account to enable moderator tools. Your current session was kept.', - ); - return false; - } - - if (!twitch_scopes.hasModeratorScopes(info.scopes)) { - _showModeratorUpgradeFailed( - 'Moderator permissions were not granted. Your current session was kept.', - ); - return false; - } - - // Commit only after all gates pass. - _token = token; - _setGrantedScopes(info.scopes); - await Future.wait([ - _storage.write(key: _userTokenKey, value: token), - _persistModeratorOptIn(currentUserId), - ]); - await user.refreshModeratedChannels(); - - navigatorKey.currentState?.pop(); - return true; - } catch (e) { - debugPrint('Moderator upgrade failed due to $e'); - _showModeratorUpgradeFailed( - 'Could not enable moderator tools. Your current session was kept.', - ); - return false; - } - } - - void _showModeratorUpgradeFailed(String message) { - final context = navigatorKey.currentContext; - if (context == null) return; - showDialog( - context: context, - builder: (dialogContext) => FrostyDialog( - title: 'Moderator tools not enabled', - message: message, - actions: [ - TextButton( - onPressed: Navigator.of(dialogContext).pop, - child: const Text('OK'), - ), - ], - ), - ); - } - /// Logs out the current user and updates fields accordingly. @action Future logout() async { try { _stopReconnectLoop(); - final previousUserId = user.details?.id ?? _lastUserId; - await _persistLastUserId(previousUserId); - - // Delete the existing user token and GQL token. + // Delete the existing user token. await _storage.delete(key: _userTokenKey); - await _storage.delete(key: _gqlTokenKey); _token = null; - _gqlToken = null; - _setGrantedScopes(const []); // Clear the user info. user.dispose(); @@ -531,16 +300,13 @@ abstract class AuthBase with Store { _token = await _storage.read(key: _defaultTokenKey); // If the default token does not already exist or it's invalid, get the new default token and store it. - if (_token == null || - await twitchApi.validateToken(token: _token!) == null) { + if (_token == null || !await twitchApi.validateToken(token: _token!)) { _token = await twitchApi.getDefaultToken(); await _storage.write(key: _defaultTokenKey, value: _token); } // Set the login status to logged out. _isLoggedIn = false; - FirebaseCrashlytics.instance.setCustomKey('is_logged_in', false); - FirebaseCrashlytics.instance.setUserIdentifier(''); debugPrint('Successfully logged out'); } catch (e) { @@ -555,9 +321,6 @@ abstract class AuthBase with Store { try { _reconnectAttempts++; if (_reconnectAttempts > _maxReconnectAttempts) { - FirebaseCrashlytics.instance.log( - 'Auth reconnection exhausted after $_maxReconnectAttempts attempts', - ); await logout(); return; } @@ -568,19 +331,17 @@ abstract class AuthBase with Store { return; } - final info = await twitchApi.validateToken(token: stored); - if (info == null) { + final isValid = await twitchApi.validateToken(token: stored); + if (!isValid) { await logout(); return; } // Token valid again — restore session. _token = stored; - _setGrantedScopes(info.scopes); await user.init(); if (user.details != null) { _isLoggedIn = true; - await _persistLastUserId(user.details!.id); _error = null; _stopReconnectLoop(); } diff --git a/lib/screens/settings/stores/auth_store.g.dart b/lib/screens/settings/stores/auth_store.g.dart index d50e1ed4..e3f1a04f 100644 --- a/lib/screens/settings/stores/auth_store.g.dart +++ b/lib/screens/settings/stores/auth_store.g.dart @@ -9,14 +9,6 @@ part of 'auth_store.dart'; // ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers mixin _$AuthStore on AuthBase, Store { - Computed? _$hasModeratorScopesComputed; - - @override - bool get hasModeratorScopes => - (_$hasModeratorScopesComputed ??= Computed( - () => super.hasModeratorScopes, - name: 'AuthBase.hasModeratorScopes', - )).value; Computed>? _$headersTwitchComputed; @override @@ -43,26 +35,6 @@ mixin _$AuthStore on AuthBase, Store { }); } - late final _$_gqlTokenAtom = Atom( - name: 'AuthBase._gqlToken', - context: context, - ); - - String? get gqlToken { - _$_gqlTokenAtom.reportRead(); - return super._gqlToken; - } - - @override - String? get _gqlToken => gqlToken; - - @override - set _gqlToken(String? value) { - _$_gqlTokenAtom.reportWrite(value, super._gqlToken, () { - super._gqlToken = value; - }); - } - late final _$_isLoggedInAtom = Atom( name: 'AuthBase._isLoggedIn', context: context, @@ -83,26 +55,6 @@ mixin _$AuthStore on AuthBase, Store { }); } - late final _$_grantedScopesAtom = Atom( - name: 'AuthBase._grantedScopes', - context: context, - ); - - ObservableList get grantedScopes { - _$_grantedScopesAtom.reportRead(); - return super._grantedScopes; - } - - @override - ObservableList get _grantedScopes => grantedScopes; - - @override - set _grantedScopes(ObservableList value) { - _$_grantedScopesAtom.reportWrite(value, super._grantedScopes, () { - super._grantedScopes = value; - }); - } - late final _$_errorAtom = Atom(name: 'AuthBase._error', context: context); String? get error { @@ -120,18 +72,6 @@ mixin _$AuthStore on AuthBase, Store { }); } - late final _$invalidateGqlTokenAsyncAction = AsyncAction( - 'AuthBase.invalidateGqlToken', - context: context, - ); - - @override - Future invalidateGqlToken() { - return _$invalidateGqlTokenAsyncAction.run( - () => super.invalidateGqlToken(), - ); - } - late final _$initAsyncAction = AsyncAction('AuthBase.init', context: context); @override @@ -149,18 +89,6 @@ mixin _$AuthStore on AuthBase, Store { return _$loginAsyncAction.run(() => super.login(token: token)); } - late final _$completeModeratorUpgradeAsyncAction = AsyncAction( - 'AuthBase.completeModeratorUpgrade', - context: context, - ); - - @override - Future completeModeratorUpgrade({required String token}) { - return _$completeModeratorUpgradeAsyncAction.run( - () => super.completeModeratorUpgrade(token: token), - ); - } - late final _$logoutAsyncAction = AsyncAction( 'AuthBase.logout', context: context, @@ -174,7 +102,6 @@ mixin _$AuthStore on AuthBase, Store { @override String toString() { return ''' -hasModeratorScopes: ${hasModeratorScopes}, headersTwitch: ${headersTwitch} '''; } diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index 031b2c53..d28006bd 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -1,4 +1,3 @@ -import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:mobx/mobx.dart'; @@ -9,11 +8,8 @@ part 'settings_store.g.dart'; class SettingsStore extends _SettingsStoreBase with _$SettingsStore { SettingsStore(); - factory SettingsStore.fromJson(Map json) { - final store = _$SettingsStoreFromJson(json); - store._migrateNativePlayerDefault(); - return store; - } + factory SettingsStore.fromJson(Map json) => + _$SettingsStoreFromJson(json); Map toJson() => _$SettingsStoreToJson(this); } @@ -53,24 +49,6 @@ abstract class _SettingsStoreBase with Store { @observable var launchUrlExternal = defaultLaunchUrlExternal; - /// ISO 639-1 language codes for Top / category / tag stream lists. - /// Empty = show all languages (no filter). Default Deutsch like a DE Twitch account. - static const defaultStreamLanguages = ['de']; - - @JsonKey(defaultValue: defaultStreamLanguages) - @observable - var streamLanguages = defaultStreamLanguages; - - /// Persistent viewer sort for Top / category / tag directories. - static const defaultStreamViewerSort = StreamViewerSort.viewersDesc; - - @JsonKey( - defaultValue: defaultStreamViewerSort, - unknownEnumValue: StreamViewerSort.viewersDesc, - ) - @observable - var streamViewerSort = defaultStreamViewerSort; - @action void resetGeneralSettings() { themeType = defaultThemeType; @@ -80,8 +58,6 @@ abstract class _SettingsStoreBase with Store { showThumbnails = defaultShowThumbnails; launchUrlExternal = defaultLaunchUrlExternal; - streamLanguages = defaultStreamLanguages; - streamViewerSort = defaultStreamViewerSort; } // * Video Settings @@ -89,9 +65,8 @@ abstract class _SettingsStoreBase with Store { static const defaultShowVideo = true; static const defaultDefaultToHighestQuality = false; static const defaultUseTextureRendering = true; - static const defaultUseNativePlayer = true; - static const defaultBlockTwitchAds = true; + // Overlay defaults static const defaultShowOverlay = true; static const defaultToggleableOverlay = false; static const defaultShowLatency = false; @@ -109,29 +84,6 @@ abstract class _SettingsStoreBase with Store { @observable var useTextureRendering = defaultUseTextureRendering; - @JsonKey(defaultValue: defaultUseNativePlayer) - @observable - var useNativePlayer = defaultUseNativePlayer; - - /// TwitchAdSolutions (vaft)-style ad blocking for the native player, and - /// userscript injection for the legacy WebView player. - @JsonKey(defaultValue: defaultBlockTwitchAds) - @observable - var blockTwitchAds = defaultBlockTwitchAds; - - /// 5.2.0 made native the default. 5.1.0 already wrote - /// `useNativePlayer: false` into saved settings, so a Dart default - /// change alone would leave existing installs on WebView. - @JsonKey(defaultValue: false) - var nativePlayerV52Migrated = false; - - @action - void _migrateNativePlayerDefault() { - if (nativePlayerV52Migrated) return; - useNativePlayer = true; - nativePlayerV52Migrated = true; - } - // Overlay options @JsonKey(defaultValue: defaultShowOverlay) @observable @@ -150,8 +102,6 @@ abstract class _SettingsStoreBase with Store { showVideo = defaultShowVideo; defaultToHighestQuality = defaultDefaultToHighestQuality; useTextureRendering = defaultUseTextureRendering; - useNativePlayer = defaultUseNativePlayer; - blockTwitchAds = defaultBlockTwitchAds; showOverlay = defaultShowOverlay; toggleableOverlay = defaultToggleableOverlay; @@ -159,27 +109,44 @@ abstract class _SettingsStoreBase with Store { } // * Chat Settings - - // -- Messages -- - // Sizing defaults + // Message sizing defaults static const defaultBadgeScale = 1.0; static const defaultEmoteScale = 1.0; static const defaultMessageScale = 1.0; static const defaultMessageSpacing = 8.0; static const defaultFontSize = 12.0; - // Appearance defaults + // Message appearance defaults static const defaultShowDeletedMessages = false; static const defaultShowChatMessageDividers = false; static const defaultTimestampType = TimestampType.disabled; - static const defaultShowHistoricalTimestamps = false; - static const defaultFocusCurrentChannel = false; + + // Delay defaults + static const defaultAutoSyncChatDelay = false; + static const defaultChatDelay = 0.0; // Alert defaults static const defaultHighlightFirstTimeChatter = true; static const defaultShowUserNotices = true; - // -- Emotes & Badges -- + // Layout defaults + static const defaultEmoteMenuButtonOnLeft = false; + + // Landscape mode defaults + static const defaultLandscapeChatLeftSide = false; + static const defaultLandscapeForceVerticalChat = false; + static const defaultLandscapeCutout = LandscapeCutoutType.none; + static const defaultChatWidth = 0.2; + static const defaultFullScreenChatOverlayOpacity = 0.5; + + // mute words defaults + static const defaultMutedWords = []; + static const defaultMatchWholeWord = true; + + // Autocomplete defaults + static const defaultAutocomplete = true; + + // Emotes and badges defaults static const defaultShowTwitchEmotes = true; static const defaultShowTwitchBadges = true; static const defaultShow7TVEmotes = true; @@ -188,26 +155,14 @@ abstract class _SettingsStoreBase with Store { static const defaultShowFFZEmotes = true; static const defaultShowFFZBadges = true; - // -- Layout -- - static const defaultEmoteMenuButtonOnLeft = false; + // Recent messages defaults + static const defaultShowRecentMessages = false; + + // Chat tabs defaults static const defaultPersistChatTabs = true; static const defaultSecondaryTabs = []; - static const defaultLandscapeChatLeftSide = false; - static const defaultLandscapeForceVerticalChat = false; - static const defaultLandscapeCutout = LandscapeCutoutType.none; - static const defaultChatWidth = 0.2; - static const defaultFullScreenChatOverlayOpacity = 0.5; - // -- Behavior -- - static const defaultKeepScreenAwake = true; - static const defaultAutocomplete = true; - static const defaultShowRecentMessages = false; - static const defaultAutoSyncChatDelay = false; - static const defaultChatDelay = 0.0; - static const defaultMutedWords = []; - static const defaultMatchWholeWord = true; - - // Messages options + // Message sizing options @JsonKey(defaultValue: defaultBadgeScale) @observable var badgeScale = defaultBadgeScale; @@ -228,6 +183,7 @@ abstract class _SettingsStoreBase with Store { @observable var fontSize = defaultFontSize; + // Message appearance options @JsonKey(defaultValue: defaultShowDeletedMessages) @observable var showDeletedMessages = defaultShowDeletedMessages; @@ -243,14 +199,16 @@ abstract class _SettingsStoreBase with Store { @observable var timestampType = defaultTimestampType; - @JsonKey(defaultValue: defaultShowHistoricalTimestamps) + // Delay options + @JsonKey(defaultValue: defaultAutoSyncChatDelay) @observable - var showHistoricalTimestamps = defaultShowHistoricalTimestamps; + var autoSyncChatDelay = defaultAutoSyncChatDelay; - @JsonKey(defaultValue: defaultFocusCurrentChannel) + @JsonKey(defaultValue: defaultChatDelay) @observable - var focusCurrentChannel = defaultFocusCurrentChannel; + var chatDelay = defaultChatDelay; + // Alert options @JsonKey(defaultValue: defaultHighlightFirstTimeChatter) @observable var highlightFirstTimeChatter = defaultHighlightFirstTimeChatter; @@ -259,7 +217,38 @@ abstract class _SettingsStoreBase with Store { @observable var showUserNotices = defaultShowUserNotices; - // Emotes & Badges options + // Layout options + @JsonKey(defaultValue: defaultEmoteMenuButtonOnLeft) + @observable + var emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft; + + // Landscape mode options + @JsonKey(defaultValue: defaultLandscapeChatLeftSide) + @observable + var landscapeChatLeftSide = defaultLandscapeChatLeftSide; + + @JsonKey(defaultValue: defaultLandscapeForceVerticalChat) + @observable + var landscapeForceVerticalChat = defaultLandscapeForceVerticalChat; + + @JsonKey(defaultValue: defaultLandscapeCutout) + @observable + var landscapeCutout = defaultLandscapeCutout; + + @JsonKey(defaultValue: defaultChatWidth) + @observable + var chatWidth = defaultChatWidth; + + @JsonKey(defaultValue: defaultFullScreenChatOverlayOpacity) + @observable + var fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity; + + // Autocomplete options + @JsonKey(defaultValue: defaultAutocomplete) + @observable + var autocomplete = defaultAutocomplete; + + // Emotes and badges @JsonKey(defaultValue: defaultShowTwitchEmotes) @observable var showTwitchEmotes = defaultShowTwitchEmotes; @@ -288,11 +277,12 @@ abstract class _SettingsStoreBase with Store { @observable var showFFZBadges = defaultShowFFZBadges; - // Layout options - @JsonKey(defaultValue: defaultEmoteMenuButtonOnLeft) + // Recent messages + @JsonKey(defaultValue: defaultShowRecentMessages) @observable - var emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft; + var showRecentMessages = defaultShowRecentMessages; + // Chat tabs @JsonKey(defaultValue: defaultPersistChatTabs) @observable var persistChatTabs = defaultPersistChatTabs; @@ -301,55 +291,6 @@ abstract class _SettingsStoreBase with Store { @observable var secondaryTabs = defaultSecondaryTabs; - @JsonKey(defaultValue: defaultLandscapeChatLeftSide) - @observable - var landscapeChatLeftSide = defaultLandscapeChatLeftSide; - - @JsonKey(defaultValue: defaultLandscapeForceVerticalChat) - @observable - var landscapeForceVerticalChat = defaultLandscapeForceVerticalChat; - - @JsonKey(defaultValue: defaultLandscapeCutout) - @observable - var landscapeCutout = defaultLandscapeCutout; - - @JsonKey(defaultValue: defaultChatWidth) - @observable - var chatWidth = defaultChatWidth; - - @JsonKey(defaultValue: defaultFullScreenChatOverlayOpacity) - @observable - var fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity; - - // Behavior options - @JsonKey(defaultValue: defaultKeepScreenAwake) - @observable - var keepScreenAwake = defaultKeepScreenAwake; - - @JsonKey(defaultValue: defaultAutocomplete) - @observable - var autocomplete = defaultAutocomplete; - - @JsonKey(defaultValue: defaultShowRecentMessages) - @observable - var showRecentMessages = defaultShowRecentMessages; - - @JsonKey(defaultValue: defaultAutoSyncChatDelay) - @observable - var autoSyncChatDelay = defaultAutoSyncChatDelay; - - @JsonKey(defaultValue: defaultChatDelay) - @observable - var chatDelay = defaultChatDelay; - - @JsonKey(includeFromJson: false, includeToJson: false) - @observable - var syncedChatDelay = defaultChatDelay; - - @computed - double get effectiveChatDelay => - autoSyncChatDelay ? syncedChatDelay : chatDelay; - @JsonKey(defaultValue: defaultMutedWords) @observable List mutedWords = defaultMutedWords; @@ -360,21 +301,35 @@ abstract class _SettingsStoreBase with Store { @action void resetChatSettings() { - // Messages badgeScale = defaultBadgeScale; emoteScale = defaultEmoteScale; messageScale = defaultMessageScale; messageSpacing = defaultMessageSpacing; fontSize = defaultFontSize; + showDeletedMessages = defaultShowDeletedMessages; showChatMessageDividers = defaultShowChatMessageDividers; timestampType = defaultTimestampType; - showHistoricalTimestamps = defaultShowHistoricalTimestamps; - focusCurrentChannel = defaultFocusCurrentChannel; + + autoSyncChatDelay = defaultAutoSyncChatDelay; + chatDelay = defaultChatDelay; + highlightFirstTimeChatter = defaultHighlightFirstTimeChatter; showUserNotices = defaultShowUserNotices; - // Emotes & Badges + emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft; + + landscapeChatLeftSide = defaultLandscapeChatLeftSide; + landscapeForceVerticalChat = defaultLandscapeForceVerticalChat; + landscapeCutout = defaultLandscapeCutout; + chatWidth = defaultChatWidth; + fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity; + + mutedWords = defaultMutedWords; + matchWholeWord = defaultMatchWholeWord; + + autocomplete = defaultAutocomplete; + showTwitchEmotes = defaultShowTwitchEmotes; showTwitchBadges = defaultShowTwitchBadges; show7TVEmotes = defaultShow7TVEmotes; @@ -383,25 +338,10 @@ abstract class _SettingsStoreBase with Store { showFFZEmotes = defaultShowFFZEmotes; showFFZBadges = defaultShowFFZBadges; - // Layout - emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft; + showRecentMessages = defaultShowRecentMessages; + persistChatTabs = defaultPersistChatTabs; secondaryTabs = defaultSecondaryTabs; - landscapeChatLeftSide = defaultLandscapeChatLeftSide; - landscapeForceVerticalChat = defaultLandscapeForceVerticalChat; - landscapeCutout = defaultLandscapeCutout; - chatWidth = defaultChatWidth; - fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity; - - // Behavior - keepScreenAwake = defaultKeepScreenAwake; - autocomplete = defaultAutocomplete; - showRecentMessages = defaultShowRecentMessages; - autoSyncChatDelay = defaultAutoSyncChatDelay; - chatDelay = defaultChatDelay; - syncedChatDelay = defaultChatDelay; - mutedWords = defaultMutedWords; - matchWholeWord = defaultMatchWholeWord; } // * Other settings @@ -461,3 +401,4 @@ enum TimestampType { disabled, twelve, twentyFour } const landscapeCutoutNames = ['None', 'Left', 'Right', 'Both']; enum LandscapeCutoutType { none, left, right, both } + diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index 797941cf..bcf06d8a 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -20,24 +20,9 @@ SettingsStore _$SettingsStoreFromJson( ..showThumbnails = json['showThumbnails'] as bool? ?? true ..largeStreamCard = json['largeStreamCard'] as bool? ?? false ..launchUrlExternal = json['launchUrlExternal'] as bool? ?? false - ..streamLanguages = - (json['streamLanguages'] as List?) - ?.map((e) => e as String) - .toList() ?? - ['de'] - ..streamViewerSort = - $enumDecodeNullable( - _$StreamViewerSortEnumMap, - json['streamViewerSort'], - unknownValue: StreamViewerSort.viewersDesc, - ) ?? - StreamViewerSort.viewersDesc ..showVideo = json['showVideo'] as bool? ?? true ..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false ..useTextureRendering = json['useTextureRendering'] as bool? ?? true - ..useNativePlayer = json['useNativePlayer'] as bool? ?? true - ..blockTwitchAds = json['blockTwitchAds'] as bool? ?? true - ..nativePlayerV52Migrated = json['nativePlayerV52Migrated'] as bool? ?? false ..showOverlay = json['showOverlay'] as bool? ?? true ..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false ..showLatency = json['showLatency'] as bool? ?? false @@ -55,26 +40,12 @@ SettingsStore _$SettingsStoreFromJson( unknownValue: TimestampType.disabled, ) ?? TimestampType.disabled - ..showHistoricalTimestamps = - json['showHistoricalTimestamps'] as bool? ?? false - ..focusCurrentChannel = json['focusCurrentChannel'] as bool? ?? false + ..autoSyncChatDelay = json['autoSyncChatDelay'] as bool? ?? false + ..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0 ..highlightFirstTimeChatter = json['highlightFirstTimeChatter'] as bool? ?? true ..showUserNotices = json['showUserNotices'] as bool? ?? true - ..showTwitchEmotes = json['showTwitchEmotes'] as bool? ?? true - ..showTwitchBadges = json['showTwitchBadges'] as bool? ?? true - ..show7TVEmotes = json['show7TVEmotes'] as bool? ?? true - ..showBTTVEmotes = json['showBTTVEmotes'] as bool? ?? true - ..showBTTVBadges = json['showBTTVBadges'] as bool? ?? true - ..showFFZEmotes = json['showFFZEmotes'] as bool? ?? true - ..showFFZBadges = json['showFFZBadges'] as bool? ?? true ..emoteMenuButtonOnLeft = json['emoteMenuButtonOnLeft'] as bool? ?? false - ..persistChatTabs = json['persistChatTabs'] as bool? ?? true - ..secondaryTabs = - (json['secondaryTabs'] as List?) - ?.map((e) => PersistedChatTab.fromJson(e as Map)) - .toList() ?? - [] ..landscapeChatLeftSide = json['landscapeChatLeftSide'] as bool? ?? false ..landscapeForceVerticalChat = json['landscapeForceVerticalChat'] as bool? ?? false @@ -87,11 +58,21 @@ SettingsStore _$SettingsStoreFromJson( ..chatWidth = (json['chatWidth'] as num?)?.toDouble() ?? 0.2 ..fullScreenChatOverlayOpacity = (json['fullScreenChatOverlayOpacity'] as num?)?.toDouble() ?? 0.5 - ..keepScreenAwake = json['keepScreenAwake'] as bool? ?? true ..autocomplete = json['autocomplete'] as bool? ?? true + ..showTwitchEmotes = json['showTwitchEmotes'] as bool? ?? true + ..showTwitchBadges = json['showTwitchBadges'] as bool? ?? true + ..show7TVEmotes = json['show7TVEmotes'] as bool? ?? true + ..showBTTVEmotes = json['showBTTVEmotes'] as bool? ?? true + ..showBTTVBadges = json['showBTTVBadges'] as bool? ?? true + ..showFFZEmotes = json['showFFZEmotes'] as bool? ?? true + ..showFFZBadges = json['showFFZBadges'] as bool? ?? true ..showRecentMessages = json['showRecentMessages'] as bool? ?? false - ..autoSyncChatDelay = json['autoSyncChatDelay'] as bool? ?? false - ..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0 + ..persistChatTabs = json['persistChatTabs'] as bool? ?? true + ..secondaryTabs = + (json['secondaryTabs'] as List?) + ?.map((e) => PersistedChatTab.fromJson(e as Map)) + .toList() ?? + [] ..mutedWords = (json['mutedWords'] as List?) ?.map((e) => e as String) @@ -116,14 +97,9 @@ Map _$SettingsStoreToJson( 'showThumbnails': instance.showThumbnails, 'largeStreamCard': instance.largeStreamCard, 'launchUrlExternal': instance.launchUrlExternal, - 'streamLanguages': instance.streamLanguages, - 'streamViewerSort': _$StreamViewerSortEnumMap[instance.streamViewerSort]!, 'showVideo': instance.showVideo, 'defaultToHighestQuality': instance.defaultToHighestQuality, 'useTextureRendering': instance.useTextureRendering, - 'useNativePlayer': instance.useNativePlayer, - 'blockTwitchAds': instance.blockTwitchAds, - 'nativePlayerV52Migrated': instance.nativePlayerV52Migrated, 'showOverlay': instance.showOverlay, 'toggleableOverlay': instance.toggleableOverlay, 'showLatency': instance.showLatency, @@ -135,10 +111,17 @@ Map _$SettingsStoreToJson( 'showDeletedMessages': instance.showDeletedMessages, 'showChatMessageDividers': instance.showChatMessageDividers, 'timestampType': _$TimestampTypeEnumMap[instance.timestampType]!, - 'showHistoricalTimestamps': instance.showHistoricalTimestamps, - 'focusCurrentChannel': instance.focusCurrentChannel, + 'autoSyncChatDelay': instance.autoSyncChatDelay, + 'chatDelay': instance.chatDelay, 'highlightFirstTimeChatter': instance.highlightFirstTimeChatter, 'showUserNotices': instance.showUserNotices, + 'emoteMenuButtonOnLeft': instance.emoteMenuButtonOnLeft, + 'landscapeChatLeftSide': instance.landscapeChatLeftSide, + 'landscapeForceVerticalChat': instance.landscapeForceVerticalChat, + 'landscapeCutout': _$LandscapeCutoutTypeEnumMap[instance.landscapeCutout]!, + 'chatWidth': instance.chatWidth, + 'fullScreenChatOverlayOpacity': instance.fullScreenChatOverlayOpacity, + 'autocomplete': instance.autocomplete, 'showTwitchEmotes': instance.showTwitchEmotes, 'showTwitchBadges': instance.showTwitchBadges, 'show7TVEmotes': instance.show7TVEmotes, @@ -146,19 +129,9 @@ Map _$SettingsStoreToJson( 'showBTTVBadges': instance.showBTTVBadges, 'showFFZEmotes': instance.showFFZEmotes, 'showFFZBadges': instance.showFFZBadges, - 'emoteMenuButtonOnLeft': instance.emoteMenuButtonOnLeft, + 'showRecentMessages': instance.showRecentMessages, 'persistChatTabs': instance.persistChatTabs, 'secondaryTabs': instance.secondaryTabs, - 'landscapeChatLeftSide': instance.landscapeChatLeftSide, - 'landscapeForceVerticalChat': instance.landscapeForceVerticalChat, - 'landscapeCutout': _$LandscapeCutoutTypeEnumMap[instance.landscapeCutout]!, - 'chatWidth': instance.chatWidth, - 'fullScreenChatOverlayOpacity': instance.fullScreenChatOverlayOpacity, - 'keepScreenAwake': instance.keepScreenAwake, - 'autocomplete': instance.autocomplete, - 'showRecentMessages': instance.showRecentMessages, - 'autoSyncChatDelay': instance.autoSyncChatDelay, - 'chatDelay': instance.chatDelay, 'mutedWords': instance.mutedWords, 'matchWholeWord': instance.matchWholeWord, 'shareCrashLogsAndAnalytics': instance.shareCrashLogsAndAnalytics, @@ -173,11 +146,6 @@ const _$ThemeTypeEnumMap = { ThemeType.dark: 'dark', }; -const _$StreamViewerSortEnumMap = { - StreamViewerSort.viewersDesc: 'viewersDesc', - StreamViewerSort.viewersAsc: 'viewersAsc', -}; - const _$TimestampTypeEnumMap = { TimestampType.disabled: 'disabled', TimestampType.twelve: 'twelve', @@ -198,15 +166,6 @@ const _$LandscapeCutoutTypeEnumMap = { // 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 _$SettingsStore on _SettingsStoreBase, Store { - Computed? _$effectiveChatDelayComputed; - - @override - double get effectiveChatDelay => - (_$effectiveChatDelayComputed ??= Computed( - () => super.effectiveChatDelay, - name: '_SettingsStoreBase.effectiveChatDelay', - )).value; - late final _$themeTypeAtom = Atom( name: '_SettingsStoreBase.themeType', context: context, @@ -297,42 +256,6 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } - late final _$streamLanguagesAtom = Atom( - name: '_SettingsStoreBase.streamLanguages', - context: context, - ); - - @override - List get streamLanguages { - _$streamLanguagesAtom.reportRead(); - return super.streamLanguages; - } - - @override - set streamLanguages(List value) { - _$streamLanguagesAtom.reportWrite(value, super.streamLanguages, () { - super.streamLanguages = value; - }); - } - - late final _$streamViewerSortAtom = Atom( - name: '_SettingsStoreBase.streamViewerSort', - context: context, - ); - - @override - StreamViewerSort get streamViewerSort { - _$streamViewerSortAtom.reportRead(); - return super.streamViewerSort; - } - - @override - set streamViewerSort(StreamViewerSort value) { - _$streamViewerSortAtom.reportWrite(value, super.streamViewerSort, () { - super.streamViewerSort = value; - }); - } - late final _$showVideoAtom = Atom( name: '_SettingsStoreBase.showVideo', context: context, @@ -391,42 +314,6 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } - late final _$useNativePlayerAtom = Atom( - name: '_SettingsStoreBase.useNativePlayer', - context: context, - ); - - @override - bool get useNativePlayer { - _$useNativePlayerAtom.reportRead(); - return super.useNativePlayer; - } - - @override - set useNativePlayer(bool value) { - _$useNativePlayerAtom.reportWrite(value, super.useNativePlayer, () { - super.useNativePlayer = value; - }); - } - - late final _$blockTwitchAdsAtom = Atom( - name: '_SettingsStoreBase.blockTwitchAds', - context: context, - ); - - @override - bool get blockTwitchAds { - _$blockTwitchAdsAtom.reportRead(); - return super.blockTwitchAds; - } - - @override - set blockTwitchAds(bool value) { - _$blockTwitchAdsAtom.reportWrite(value, super.blockTwitchAds, () { - super.blockTwitchAds = value; - }); - } - late final _$showOverlayAtom = Atom( name: '_SettingsStoreBase.showOverlay', context: context, @@ -629,43 +516,39 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } - late final _$showHistoricalTimestampsAtom = Atom( - name: '_SettingsStoreBase.showHistoricalTimestamps', + late final _$autoSyncChatDelayAtom = Atom( + name: '_SettingsStoreBase.autoSyncChatDelay', context: context, ); @override - bool get showHistoricalTimestamps { - _$showHistoricalTimestampsAtom.reportRead(); - return super.showHistoricalTimestamps; + bool get autoSyncChatDelay { + _$autoSyncChatDelayAtom.reportRead(); + return super.autoSyncChatDelay; } @override - set showHistoricalTimestamps(bool value) { - _$showHistoricalTimestampsAtom.reportWrite( - value, - super.showHistoricalTimestamps, - () { - super.showHistoricalTimestamps = value; - }, - ); + set autoSyncChatDelay(bool value) { + _$autoSyncChatDelayAtom.reportWrite(value, super.autoSyncChatDelay, () { + super.autoSyncChatDelay = value; + }); } - late final _$focusCurrentChannelAtom = Atom( - name: '_SettingsStoreBase.focusCurrentChannel', + late final _$chatDelayAtom = Atom( + name: '_SettingsStoreBase.chatDelay', context: context, ); @override - bool get focusCurrentChannel { - _$focusCurrentChannelAtom.reportRead(); - return super.focusCurrentChannel; + double get chatDelay { + _$chatDelayAtom.reportRead(); + return super.chatDelay; } @override - set focusCurrentChannel(bool value) { - _$focusCurrentChannelAtom.reportWrite(value, super.focusCurrentChannel, () { - super.focusCurrentChannel = value; + set chatDelay(double value) { + _$chatDelayAtom.reportWrite(value, super.chatDelay, () { + super.chatDelay = value; }); } @@ -709,6 +592,148 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } + late final _$emoteMenuButtonOnLeftAtom = Atom( + name: '_SettingsStoreBase.emoteMenuButtonOnLeft', + context: context, + ); + + @override + bool get emoteMenuButtonOnLeft { + _$emoteMenuButtonOnLeftAtom.reportRead(); + return super.emoteMenuButtonOnLeft; + } + + @override + set emoteMenuButtonOnLeft(bool value) { + _$emoteMenuButtonOnLeftAtom.reportWrite( + value, + super.emoteMenuButtonOnLeft, + () { + super.emoteMenuButtonOnLeft = value; + }, + ); + } + + late final _$landscapeChatLeftSideAtom = Atom( + name: '_SettingsStoreBase.landscapeChatLeftSide', + context: context, + ); + + @override + bool get landscapeChatLeftSide { + _$landscapeChatLeftSideAtom.reportRead(); + return super.landscapeChatLeftSide; + } + + @override + set landscapeChatLeftSide(bool value) { + _$landscapeChatLeftSideAtom.reportWrite( + value, + super.landscapeChatLeftSide, + () { + super.landscapeChatLeftSide = value; + }, + ); + } + + late final _$landscapeForceVerticalChatAtom = Atom( + name: '_SettingsStoreBase.landscapeForceVerticalChat', + context: context, + ); + + @override + bool get landscapeForceVerticalChat { + _$landscapeForceVerticalChatAtom.reportRead(); + return super.landscapeForceVerticalChat; + } + + @override + set landscapeForceVerticalChat(bool value) { + _$landscapeForceVerticalChatAtom.reportWrite( + value, + super.landscapeForceVerticalChat, + () { + super.landscapeForceVerticalChat = value; + }, + ); + } + + late final _$landscapeCutoutAtom = Atom( + name: '_SettingsStoreBase.landscapeCutout', + context: context, + ); + + @override + LandscapeCutoutType get landscapeCutout { + _$landscapeCutoutAtom.reportRead(); + return super.landscapeCutout; + } + + @override + set landscapeCutout(LandscapeCutoutType value) { + _$landscapeCutoutAtom.reportWrite(value, super.landscapeCutout, () { + super.landscapeCutout = value; + }); + } + + late final _$chatWidthAtom = Atom( + name: '_SettingsStoreBase.chatWidth', + context: context, + ); + + @override + double get chatWidth { + _$chatWidthAtom.reportRead(); + return super.chatWidth; + } + + @override + set chatWidth(double value) { + _$chatWidthAtom.reportWrite(value, super.chatWidth, () { + super.chatWidth = value; + }); + } + + late final _$fullScreenChatOverlayOpacityAtom = Atom( + name: '_SettingsStoreBase.fullScreenChatOverlayOpacity', + context: context, + ); + + @override + double get fullScreenChatOverlayOpacity { + _$fullScreenChatOverlayOpacityAtom.reportRead(); + return super.fullScreenChatOverlayOpacity; + } + + @override + set fullScreenChatOverlayOpacity(double value) { + _$fullScreenChatOverlayOpacityAtom.reportWrite( + value, + super.fullScreenChatOverlayOpacity, + () { + super.fullScreenChatOverlayOpacity = value; + }, + ); + } + + late final _$autocompleteAtom = Atom( + name: '_SettingsStoreBase.autocomplete', + context: context, + ); + + @override + bool get autocomplete { + _$autocompleteAtom.reportRead(); + return super.autocomplete; + } + + @override + set autocomplete(bool value) { + _$autocompleteAtom.reportWrite(value, super.autocomplete, () { + super.autocomplete = value; + }); + } + late final _$showTwitchEmotesAtom = Atom( name: '_SettingsStoreBase.showTwitchEmotes', context: context, @@ -835,26 +860,22 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } - late final _$emoteMenuButtonOnLeftAtom = Atom( - name: '_SettingsStoreBase.emoteMenuButtonOnLeft', + late final _$showRecentMessagesAtom = Atom( + name: '_SettingsStoreBase.showRecentMessages', context: context, ); @override - bool get emoteMenuButtonOnLeft { - _$emoteMenuButtonOnLeftAtom.reportRead(); - return super.emoteMenuButtonOnLeft; + bool get showRecentMessages { + _$showRecentMessagesAtom.reportRead(); + return super.showRecentMessages; } @override - set emoteMenuButtonOnLeft(bool value) { - _$emoteMenuButtonOnLeftAtom.reportWrite( - value, - super.emoteMenuButtonOnLeft, - () { - super.emoteMenuButtonOnLeft = value; - }, - ); + set showRecentMessages(bool value) { + _$showRecentMessagesAtom.reportWrite(value, super.showRecentMessages, () { + super.showRecentMessages = value; + }); } late final _$persistChatTabsAtom = Atom( @@ -893,216 +914,6 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } - late final _$landscapeChatLeftSideAtom = Atom( - name: '_SettingsStoreBase.landscapeChatLeftSide', - context: context, - ); - - @override - bool get landscapeChatLeftSide { - _$landscapeChatLeftSideAtom.reportRead(); - return super.landscapeChatLeftSide; - } - - @override - set landscapeChatLeftSide(bool value) { - _$landscapeChatLeftSideAtom.reportWrite( - value, - super.landscapeChatLeftSide, - () { - super.landscapeChatLeftSide = value; - }, - ); - } - - late final _$landscapeForceVerticalChatAtom = Atom( - name: '_SettingsStoreBase.landscapeForceVerticalChat', - context: context, - ); - - @override - bool get landscapeForceVerticalChat { - _$landscapeForceVerticalChatAtom.reportRead(); - return super.landscapeForceVerticalChat; - } - - @override - set landscapeForceVerticalChat(bool value) { - _$landscapeForceVerticalChatAtom.reportWrite( - value, - super.landscapeForceVerticalChat, - () { - super.landscapeForceVerticalChat = value; - }, - ); - } - - late final _$landscapeCutoutAtom = Atom( - name: '_SettingsStoreBase.landscapeCutout', - context: context, - ); - - @override - LandscapeCutoutType get landscapeCutout { - _$landscapeCutoutAtom.reportRead(); - return super.landscapeCutout; - } - - @override - set landscapeCutout(LandscapeCutoutType value) { - _$landscapeCutoutAtom.reportWrite(value, super.landscapeCutout, () { - super.landscapeCutout = value; - }); - } - - late final _$chatWidthAtom = Atom( - name: '_SettingsStoreBase.chatWidth', - context: context, - ); - - @override - double get chatWidth { - _$chatWidthAtom.reportRead(); - return super.chatWidth; - } - - @override - set chatWidth(double value) { - _$chatWidthAtom.reportWrite(value, super.chatWidth, () { - super.chatWidth = value; - }); - } - - late final _$fullScreenChatOverlayOpacityAtom = Atom( - name: '_SettingsStoreBase.fullScreenChatOverlayOpacity', - context: context, - ); - - @override - double get fullScreenChatOverlayOpacity { - _$fullScreenChatOverlayOpacityAtom.reportRead(); - return super.fullScreenChatOverlayOpacity; - } - - @override - set fullScreenChatOverlayOpacity(double value) { - _$fullScreenChatOverlayOpacityAtom.reportWrite( - value, - super.fullScreenChatOverlayOpacity, - () { - super.fullScreenChatOverlayOpacity = value; - }, - ); - } - - late final _$keepScreenAwakeAtom = Atom( - name: '_SettingsStoreBase.keepScreenAwake', - context: context, - ); - - @override - bool get keepScreenAwake { - _$keepScreenAwakeAtom.reportRead(); - return super.keepScreenAwake; - } - - @override - set keepScreenAwake(bool value) { - _$keepScreenAwakeAtom.reportWrite(value, super.keepScreenAwake, () { - super.keepScreenAwake = value; - }); - } - - late final _$autocompleteAtom = Atom( - name: '_SettingsStoreBase.autocomplete', - context: context, - ); - - @override - bool get autocomplete { - _$autocompleteAtom.reportRead(); - return super.autocomplete; - } - - @override - set autocomplete(bool value) { - _$autocompleteAtom.reportWrite(value, super.autocomplete, () { - super.autocomplete = value; - }); - } - - late final _$showRecentMessagesAtom = Atom( - name: '_SettingsStoreBase.showRecentMessages', - context: context, - ); - - @override - bool get showRecentMessages { - _$showRecentMessagesAtom.reportRead(); - return super.showRecentMessages; - } - - @override - set showRecentMessages(bool value) { - _$showRecentMessagesAtom.reportWrite(value, super.showRecentMessages, () { - super.showRecentMessages = value; - }); - } - - late final _$autoSyncChatDelayAtom = Atom( - name: '_SettingsStoreBase.autoSyncChatDelay', - context: context, - ); - - @override - bool get autoSyncChatDelay { - _$autoSyncChatDelayAtom.reportRead(); - return super.autoSyncChatDelay; - } - - @override - set autoSyncChatDelay(bool value) { - _$autoSyncChatDelayAtom.reportWrite(value, super.autoSyncChatDelay, () { - super.autoSyncChatDelay = value; - }); - } - - late final _$chatDelayAtom = Atom( - name: '_SettingsStoreBase.chatDelay', - context: context, - ); - - @override - double get chatDelay { - _$chatDelayAtom.reportRead(); - return super.chatDelay; - } - - @override - set chatDelay(double value) { - _$chatDelayAtom.reportWrite(value, super.chatDelay, () { - super.chatDelay = value; - }); - } - - late final _$syncedChatDelayAtom = Atom( - name: '_SettingsStoreBase.syncedChatDelay', - context: context, - ); - - @override - double get syncedChatDelay { - _$syncedChatDelayAtom.reportRead(); - return super.syncedChatDelay; - } - - @override - set syncedChatDelay(double value) { - _$syncedChatDelayAtom.reportWrite(value, super.syncedChatDelay, () { - super.syncedChatDelay = value; - }); - } - late final _$mutedWordsAtom = Atom( name: '_SettingsStoreBase.mutedWords', context: context, @@ -1236,18 +1047,6 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { } } - @override - void _migrateNativePlayerDefault() { - final _$actionInfo = _$_SettingsStoreBaseActionController.startAction( - name: '_SettingsStoreBase._migrateNativePlayerDefault', - ); - try { - return super._migrateNativePlayerDefault(); - } finally { - _$_SettingsStoreBaseActionController.endAction(_$actionInfo); - } - } - @override void resetVideoSettings() { final _$actionInfo = _$_SettingsStoreBaseActionController.startAction( @@ -1316,13 +1115,9 @@ accentColor: ${accentColor}, showThumbnails: ${showThumbnails}, largeStreamCard: ${largeStreamCard}, launchUrlExternal: ${launchUrlExternal}, -streamLanguages: ${streamLanguages}, -streamViewerSort: ${streamViewerSort}, showVideo: ${showVideo}, defaultToHighestQuality: ${defaultToHighestQuality}, useTextureRendering: ${useTextureRendering}, -useNativePlayer: ${useNativePlayer}, -blockTwitchAds: ${blockTwitchAds}, showOverlay: ${showOverlay}, toggleableOverlay: ${toggleableOverlay}, showLatency: ${showLatency}, @@ -1334,10 +1129,17 @@ fontSize: ${fontSize}, showDeletedMessages: ${showDeletedMessages}, showChatMessageDividers: ${showChatMessageDividers}, timestampType: ${timestampType}, -showHistoricalTimestamps: ${showHistoricalTimestamps}, -focusCurrentChannel: ${focusCurrentChannel}, +autoSyncChatDelay: ${autoSyncChatDelay}, +chatDelay: ${chatDelay}, highlightFirstTimeChatter: ${highlightFirstTimeChatter}, showUserNotices: ${showUserNotices}, +emoteMenuButtonOnLeft: ${emoteMenuButtonOnLeft}, +landscapeChatLeftSide: ${landscapeChatLeftSide}, +landscapeForceVerticalChat: ${landscapeForceVerticalChat}, +landscapeCutout: ${landscapeCutout}, +chatWidth: ${chatWidth}, +fullScreenChatOverlayOpacity: ${fullScreenChatOverlayOpacity}, +autocomplete: ${autocomplete}, showTwitchEmotes: ${showTwitchEmotes}, showTwitchBadges: ${showTwitchBadges}, show7TVEmotes: ${show7TVEmotes}, @@ -1345,27 +1147,15 @@ showBTTVEmotes: ${showBTTVEmotes}, showBTTVBadges: ${showBTTVBadges}, showFFZEmotes: ${showFFZEmotes}, showFFZBadges: ${showFFZBadges}, -emoteMenuButtonOnLeft: ${emoteMenuButtonOnLeft}, +showRecentMessages: ${showRecentMessages}, persistChatTabs: ${persistChatTabs}, secondaryTabs: ${secondaryTabs}, -landscapeChatLeftSide: ${landscapeChatLeftSide}, -landscapeForceVerticalChat: ${landscapeForceVerticalChat}, -landscapeCutout: ${landscapeCutout}, -chatWidth: ${chatWidth}, -fullScreenChatOverlayOpacity: ${fullScreenChatOverlayOpacity}, -keepScreenAwake: ${keepScreenAwake}, -autocomplete: ${autocomplete}, -showRecentMessages: ${showRecentMessages}, -autoSyncChatDelay: ${autoSyncChatDelay}, -chatDelay: ${chatDelay}, -syncedChatDelay: ${syncedChatDelay}, mutedWords: ${mutedWords}, matchWholeWord: ${matchWholeWord}, shareCrashLogsAndAnalytics: ${shareCrashLogsAndAnalytics}, fullScreen: ${fullScreen}, fullScreenChatOverlay: ${fullScreenChatOverlay}, -pinnedChannelIds: ${pinnedChannelIds}, -effectiveChatDelay: ${effectiveChatDelay} +pinnedChannelIds: ${pinnedChannelIds} '''; } } diff --git a/lib/screens/settings/stores/user_store.dart b/lib/screens/settings/stores/user_store.dart index de29e1da..39153fe1 100644 --- a/lib/screens/settings/stores/user_store.dart +++ b/lib/screens/settings/stores/user_store.dart @@ -1,12 +1,6 @@ -import 'dart:math'; - -import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/apis/twitch_api.dart'; -import 'package:frosty/apis/twitch_gql_api.dart'; -import 'package:frosty/apis/twitch_integrity.dart'; import 'package:frosty/models/user.dart'; import 'package:mobx/mobx.dart'; -import 'package:shared_preferences/shared_preferences.dart'; part 'user_store.g.dart'; @@ -14,10 +8,6 @@ class UserStore = UserStoreBase with _$UserStore; abstract class UserStoreBase with Store { final TwitchApi twitchApi; - final TwitchGqlApi twitchGqlApi; - - /// Whether the active auth token includes Frosty's moderator OAuth scopes. - final bool Function() hasModeratorScopes; /// The current user's info. @readonly @@ -27,223 +17,28 @@ abstract class UserStoreBase with Store { @readonly var _blockedUsers = ObservableList(); - /// The list of channel IDs the user moderates. - @readonly - var _moderatedChannels = ObservableList(); - - /// Cached follow state for channels queried this session (id → following). - final _followCache = ObservableMap(); - - static const _deviceIdKey = 'twitch_gql_device_id'; - - String? _deviceId; - String? _integrityToken; - int _integrityExpirationMs = 0; - ReactionDisposer? _disposeReaction; - UserStoreBase({ - required this.twitchApi, - required this.twitchGqlApi, - required this.hasModeratorScopes, - }); + UserStoreBase({required this.twitchApi}); @action Future init() async { // Get and update the current user's info. _details = await twitchApi.getUserInfo(); - // Get and update non-critical user info. + // Get and update the current user's list of blocked users. // Don't use await because having a huge list of blocked users will block the UI. if (_details?.id != null) { twitchApi .getUserBlockedList(id: _details!.id) .then((blockedUsers) => _blockedUsers = blockedUsers.asObservable()); - // Never hit moderated-channels Helix without moderator scopes — that 401 - // is what nags non-mod users after an app upgrade. - if (hasModeratorScopes()) { - twitchApi - .getModeratedChannels(id: _details!.id) - .then( - (channels) => _moderatedChannels = channels.asObservable(), - ); - } else { - _moderatedChannels = ObservableList(); - } } - _disposeReaction?.call(); _disposeReaction = autorun( (_) => _blockedUsers.sort((a, b) => a.userLogin.compareTo(b.userLogin)), ); } - /// Whether we already know follow state for [channelId] this session. - bool? cachedIsFollowing(String channelId) => _followCache[channelId]; - - @action - Future isFollowingChannel(String channelId) async { - final cached = _followCache[channelId]; - if (cached != null) return cached; - - final userId = _details?.id; - if (userId == null) return false; - - final following = await twitchApi.isFollowingChannel( - userId: userId, - broadcasterId: channelId, - ); - _followCache[channelId] = following; - return following; - } - - /// Follows [targetId] via web GQL. [gqlToken] is the Twitch web cookie token. - @action - Future follow({ - required String targetId, - required String gqlToken, - }) async { - await _followMutation( - targetId: targetId, - gqlToken: gqlToken, - follow: true, - ); - _followCache[targetId] = true; - } - - /// Unfollows [targetId] via web GQL. - @action - Future unfollow({ - required String targetId, - required String gqlToken, - }) async { - await _followMutation( - targetId: targetId, - gqlToken: gqlToken, - follow: false, - ); - _followCache[targetId] = false; - } - - Future _followMutation({ - required String targetId, - required String gqlToken, - required bool follow, - }) async { - await runWithGqlIntegrity( - gqlToken: gqlToken, - action: ({authToken, integrityToken, deviceId}) { - if (follow) { - return twitchGqlApi.followUser( - targetId: targetId, - authToken: authToken!, - integrityToken: integrityToken, - deviceId: deviceId, - ); - } - return twitchGqlApi.unfollowUser( - targetId: targetId, - authToken: authToken!, - integrityToken: integrityToken, - deviceId: deviceId, - ); - }, - ); - } - - /// Runs a GQL call with optional Client-Integrity; refreshes once on - /// [IntegrityCheckException] when a web [gqlToken] is available. - /// - /// Dio-minted tokens that are `is_bad_bot` are discarded; callers should - /// then capture credentials via a WebView and [applyWebIntegrity]. - Future runWithGqlIntegrity({ - required String? gqlToken, - required Future Function({ - required String? authToken, - required String? integrityToken, - required String? deviceId, - }) action, - }) async { - final deviceId = await _ensureDeviceId(); - final authToken = - gqlToken != null && gqlToken.isNotEmpty ? gqlToken : null; - - Future run(String? integrityToken) => action( - authToken: authToken, - integrityToken: integrityToken, - deviceId: _deviceId ?? deviceId, - ); - - try { - return await run(_validIntegrityToken()); - } on IntegrityCheckException { - if (authToken == null) { - clearIntegrity(); - rethrow; - } - try { - final integrity = await twitchGqlApi.fetchIntegrityToken( - authToken: authToken, - deviceId: _deviceId ?? deviceId, - ); - _integrityToken = integrity.token; - _integrityExpirationMs = integrity.expirationMs; - return await run(integrity.token); - } on IntegrityCheckException { - clearIntegrity(); - rethrow; - } - } - } - - /// Stores WebView-captured integrity (must match [GqlIntegrityCredentials.deviceId]). - Future applyWebIntegrity(GqlIntegrityCredentials credentials) async { - if (TwitchIntegrity.isBadBot(credentials.integrityToken)) { - clearIntegrity(); - throw const IntegrityCheckException( - 'Twitch integrity token flagged as bad bot', - ); - } - _integrityToken = credentials.integrityToken; - _integrityExpirationMs = credentials.expirationMs; - _deviceId = credentials.deviceId; - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_deviceIdKey, credentials.deviceId); - } - - void clearIntegrity() { - _integrityToken = null; - _integrityExpirationMs = 0; - } - - String? _validIntegrityToken() { - if (_integrityToken == null) return null; - if (TwitchIntegrity.isBadBot(_integrityToken!)) { - clearIntegrity(); - return null; - } - if (DateTime.now().millisecondsSinceEpoch >= _integrityExpirationMs - 60000) { - return null; - } - return _integrityToken; - } - - Future _ensureDeviceId() async { - if (_deviceId != null) return _deviceId!; - final prefs = await SharedPreferences.getInstance(); - var id = prefs.getString(_deviceIdKey); - if (id == null || id.isEmpty) { - final random = Random.secure(); - id = List.generate( - 32, - (_) => random.nextInt(16).toRadixString(16), - ).join(); - await prefs.setString(_deviceIdKey, id); - } - _deviceId = id; - return id; - } - @action Future block({ required String targetId, @@ -266,87 +61,10 @@ abstract class UserStoreBase with Store { Future refreshBlockedUsers() async => _blockedUsers = (await twitchApi.getUserBlockedList(id: _details!.id)).asObservable(); - /// Re-fetches which channels the user moderates. Mod status is otherwise only - /// loaded once at login, so call this when entering a channel to reflect mod - /// status granted or revoked since then. - @action - Future refreshModeratedChannels() async { - if (_details?.id == null || !hasModeratorScopes()) return; - _moderatedChannels = - (await twitchApi.getModeratedChannels(id: _details!.id)).asObservable(); - } - - bool isModerator(String channelId) { - return _moderatedChannels.contains(channelId); - } - - /// Whether the logged-in user can moderate [channelId] — either a moderator - /// of that channel or its broadcaster (the broadcaster isn't in their own - /// moderator list but can still use the moderation endpoints). - /// - /// Role only — does not require moderator OAuth scopes. Use - /// [canPerformModeratorActions] before calling Helix. - bool canModerate(String channelId) => - _details?.id == channelId || isModerator(channelId); - - /// Role + moderator OAuth scopes — safe to call Helix mod endpoints. - bool canPerformModeratorActions(String channelId) => - _details?.id != null && - hasModeratorScopes() && - canModerate(channelId); - - @action - Future deleteMessage({ - required String broadcasterId, - required String messageId, - }) async { - if (!canPerformModeratorActions(broadcasterId)) return false; - return twitchApi.deleteChatMessage( - broadcasterId: broadcasterId, - moderatorId: _details!.id, - messageId: messageId, - ); - } - - @action - Future banOrTimeoutUser({ - required String broadcasterId, - required String userIdToBan, - int? duration, - String? reason, - }) async { - if (!canPerformModeratorActions(broadcasterId)) return false; - return twitchApi.banUser( - broadcasterId: broadcasterId, - moderatorId: _details!.id, - userIdToBan: userIdToBan, - duration: duration, - reason: reason, - ); - } - - /// Removes a ban or timeout for [userIdToUnban] in the channel. - @action - Future unbanUser({ - required String broadcasterId, - required String userIdToUnban, - }) async { - if (!canPerformModeratorActions(broadcasterId)) return false; - return twitchApi.unbanUser( - broadcasterId: broadcasterId, - moderatorId: _details!.id, - userId: userIdToUnban, - ); - } - @action void dispose() { _details = null; _blockedUsers.clear(); - _moderatedChannels.clear(); - _followCache.clear(); - clearIntegrity(); - _disposeReaction?.call(); - _disposeReaction = null; + if (_disposeReaction != null) _disposeReaction!(); } } diff --git a/lib/screens/settings/stores/user_store.g.dart b/lib/screens/settings/stores/user_store.g.dart index e138aa77..2e5659eb 100644 --- a/lib/screens/settings/stores/user_store.g.dart +++ b/lib/screens/settings/stores/user_store.g.dart @@ -49,26 +49,6 @@ mixin _$UserStore on UserStoreBase, Store { }); } - late final _$_moderatedChannelsAtom = Atom( - name: 'UserStoreBase._moderatedChannels', - context: context, - ); - - ObservableList get moderatedChannels { - _$_moderatedChannelsAtom.reportRead(); - return super._moderatedChannels; - } - - @override - ObservableList get _moderatedChannels => moderatedChannels; - - @override - set _moderatedChannels(ObservableList value) { - _$_moderatedChannelsAtom.reportWrite(value, super._moderatedChannels, () { - super._moderatedChannels = value; - }); - } - late final _$initAsyncAction = AsyncAction( 'UserStoreBase.init', context: context, @@ -79,42 +59,6 @@ mixin _$UserStore on UserStoreBase, Store { return _$initAsyncAction.run(() => super.init()); } - late final _$isFollowingChannelAsyncAction = AsyncAction( - 'UserStoreBase.isFollowingChannel', - context: context, - ); - - @override - Future isFollowingChannel(String channelId) { - return _$isFollowingChannelAsyncAction.run( - () => super.isFollowingChannel(channelId), - ); - } - - late final _$followAsyncAction = AsyncAction( - 'UserStoreBase.follow', - context: context, - ); - - @override - Future follow({required String targetId, required String gqlToken}) { - return _$followAsyncAction.run( - () => super.follow(targetId: targetId, gqlToken: gqlToken), - ); - } - - late final _$unfollowAsyncAction = AsyncAction( - 'UserStoreBase.unfollow', - context: context, - ); - - @override - Future unfollow({required String targetId, required String gqlToken}) { - return _$unfollowAsyncAction.run( - () => super.unfollow(targetId: targetId, gqlToken: gqlToken), - ); - } - late final _$blockAsyncAction = AsyncAction( 'UserStoreBase.block', context: context, @@ -149,76 +93,6 @@ mixin _$UserStore on UserStoreBase, Store { ); } - late final _$refreshModeratedChannelsAsyncAction = AsyncAction( - 'UserStoreBase.refreshModeratedChannels', - context: context, - ); - - @override - Future refreshModeratedChannels() { - return _$refreshModeratedChannelsAsyncAction.run( - () => super.refreshModeratedChannels(), - ); - } - - late final _$deleteMessageAsyncAction = AsyncAction( - 'UserStoreBase.deleteMessage', - context: context, - ); - - @override - Future deleteMessage({ - required String broadcasterId, - required String messageId, - }) { - return _$deleteMessageAsyncAction.run( - () => super.deleteMessage( - broadcasterId: broadcasterId, - messageId: messageId, - ), - ); - } - - late final _$banOrTimeoutUserAsyncAction = AsyncAction( - 'UserStoreBase.banOrTimeoutUser', - context: context, - ); - - @override - Future banOrTimeoutUser({ - required String broadcasterId, - required String userIdToBan, - int? duration, - String? reason, - }) { - return _$banOrTimeoutUserAsyncAction.run( - () => super.banOrTimeoutUser( - broadcasterId: broadcasterId, - userIdToBan: userIdToBan, - duration: duration, - reason: reason, - ), - ); - } - - late final _$unbanUserAsyncAction = AsyncAction( - 'UserStoreBase.unbanUser', - context: context, - ); - - @override - Future unbanUser({ - required String broadcasterId, - required String userIdToUnban, - }) { - return _$unbanUserAsyncAction.run( - () => super.unbanUser( - broadcasterId: broadcasterId, - userIdToUnban: userIdToUnban, - ), - ); - } - late final _$UserStoreBaseActionController = ActionController( name: 'UserStoreBase', context: context, diff --git a/lib/screens/settings/video_settings.dart b/lib/screens/settings/video_settings.dart index 6e3ba50e..ab48da9a 100644 --- a/lib/screens/settings/video_settings.dart +++ b/lib/screens/settings/video_settings.dart @@ -20,35 +20,10 @@ class VideoSettings extends StatelessWidget { children: [ const SectionHeader('Player', isFirst: true), SettingsListSwitch( - title: 'Show video player', + title: 'Enable video', value: settingsStore.showVideo, onChanged: (newValue) => settingsStore.showVideo = newValue, ), - if (settingsStore.showVideo) - SettingsListSwitch( - title: 'Native player', - subtitle: const Text( - 'Picture-in-Picture, quality selection, and lower latency. Turn off to use the legacy WebView player.', - ), - value: settingsStore.useNativePlayer, - onChanged: (newValue) => - settingsStore.useNativePlayer = newValue, - ), - if (settingsStore.showVideo) - SettingsListSwitch( - title: 'Block Twitch ads', - subtitle: Text( - settingsStore.useNativePlayer - ? 'Uses TwitchAdSolutions (vaft): popout/embed/autoplay player types and backup streams during ad breaks.' - : 'Injects the vaft userscript into the WebView. Native player is more reliable — enable it above if ads still play.', - ), - value: settingsStore.blockTwitchAds, - onChanged: (newValue) { - settingsStore.blockTwitchAds = newValue; - // Ad blocking is most effective on the native path. - if (newValue) settingsStore.useNativePlayer = true; - }, - ), if (!Platform.isIOS || isIPad()) SettingsListSwitch( title: 'Default to highest quality', @@ -76,13 +51,12 @@ class VideoSettings extends StatelessWidget { onChanged: (newValue) => settingsStore.showOverlay = newValue, ), SettingsListSwitch( - title: 'Toggle overlay on long-press', + title: 'Long-press player to toggle overlay', subtitle: const Text( - 'Switch between Twitch\'s overlay and the custom overlay.', + 'Allows switching between Twitch\'s overlay and the custom overlay.', ), value: settingsStore.toggleableOverlay, - onChanged: (newValue) => - settingsStore.toggleableOverlay = newValue, + onChanged: (newValue) => settingsStore.toggleableOverlay = newValue, ), SettingsListSwitch( title: 'Show latency', diff --git a/lib/screens/settings/widgets/release_notes.dart b/lib/screens/settings/widgets/release_notes.dart index a004e100..2a1e82fb 100644 --- a/lib/screens/settings/widgets/release_notes.dart +++ b/lib/screens/settings/widgets/release_notes.dart @@ -8,8 +8,6 @@ import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher_string.dart'; class ReleaseNotes extends StatefulWidget { - static const routeName = 'ReleaseNotes'; - const ReleaseNotes({super.key}); @override @@ -74,9 +72,9 @@ class _ReleaseNotesState extends State { ), child: Markdown( padding: EdgeInsets.fromLTRB( - 16 + MediaQuery.of(context).padding.left, + 16, MediaQuery.of(context).padding.top + kToolbarHeight, - 16 + MediaQuery.of(context).padding.right, + 16, 16, ), controller: _scrollController, diff --git a/lib/screens/settings/widgets/settings_list_slider.dart b/lib/screens/settings/widgets/settings_list_slider.dart index cf9979f4..e720ef8d 100644 --- a/lib/screens/settings/widgets/settings_list_slider.dart +++ b/lib/screens/settings/widgets/settings_list_slider.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; /// A custom-styled adaptive [Slider]. -class SettingsListSlider extends StatefulWidget { +class SettingsListSlider extends StatelessWidget { final String title; final String trailing; final String? subtitle; @@ -24,37 +23,15 @@ class SettingsListSlider extends StatefulWidget { this.divisions, }); - @override - State createState() => _SettingsListSliderState(); -} - -class _SettingsListSliderState extends State { - int? _previousStep; - - void _handleChanged(double newValue) { - if (widget.divisions != null) { - final step = - ((newValue - widget.min) / - (widget.max - widget.min) * - widget.divisions!) - .round(); - if (_previousStep != null && step != _previousStep) { - HapticFeedback.selectionClick(); - } - _previousStep = step; - } - widget.onChanged?.call(newValue); - } - @override Widget build(BuildContext context) { return ListTile( title: Row( children: [ - Text(widget.title), + Text(title), const Spacer(), Text( - widget.trailing, + trailing, style: const TextStyle( fontFeatures: [FontFeature.tabularFigures()], ), @@ -68,14 +45,13 @@ class _SettingsListSliderState extends State { mainAxisSize: MainAxisSize.min, children: [ Slider.adaptive( - value: widget.value, - min: widget.min, - max: widget.max, - divisions: widget.divisions, - onChanged: - widget.onChanged != null ? _handleChanged : null, + value: value, + min: min, + max: max, + divisions: divisions, + onChanged: onChanged, ), - if (widget.subtitle != null) Text(widget.subtitle!), + if (subtitle != null) Text(subtitle!), ], ), ), diff --git a/lib/screens/settings/widgets/settings_list_switch.dart b/lib/screens/settings/widgets/settings_list_switch.dart index 97669e5b..bcad439f 100644 --- a/lib/screens/settings/widgets/settings_list_switch.dart +++ b/lib/screens/settings/widgets/settings_list_switch.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; /// A custom-styled adaptive [SwitchListTile]. class SettingsListSwitch extends StatelessWidget { @@ -22,12 +21,7 @@ class SettingsListSwitch extends StatelessWidget { title: Text(title), subtitle: subtitle, value: value, - onChanged: onChanged != null - ? (newValue) { - HapticFeedback.selectionClick(); - onChanged!(newValue); - } - : null, + onChanged: onChanged, ); } } diff --git a/lib/screens/settings/widgets/settings_muted_words.dart b/lib/screens/settings/widgets/settings_muted_words.dart index 44ccebad..78f14c13 100644 --- a/lib/screens/settings/widgets/settings_muted_words.dart +++ b/lib/screens/settings/widgets/settings_muted_words.dart @@ -3,6 +3,7 @@ import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/utils/modal_bottom_sheet.dart'; import 'package:frosty/widgets/alert_message.dart'; +import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:frosty/widgets/frosty_scrollbar.dart'; class SettingsMutedWords extends StatefulWidget { @@ -96,25 +97,30 @@ class _SettingsMutedWordsState extends State { settingsStore.mutedWords.elementAt(index), ), trailing: IconButton( - icon: const Icon( - Icons.delete_outline_rounded, - ), + icon: const Icon(Icons.delete), onPressed: () { - final word = - settingsStore.mutedWords.elementAt(index); - removeMutedWord(index); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text("Removed '$word'"), - action: SnackBarAction( - label: 'Undo', - onPressed: () { - settingsStore.mutedWords = [ - ...settingsStore.mutedWords, - word, - ]; - }, - ), + // show confirmation dialog before deleting a keyword + showDialog( + context: context, + builder: (context) => FrostyDialog( + title: 'Delete keyword', + message: + 'Are you sure you want to delete this keyword?', + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + removeMutedWord(index); + Navigator.of(context).pop(); + }, + child: const Text('Delete'), + ), + ], ), ); }, diff --git a/lib/screens/settings/widgets/settings_tile_route.dart b/lib/screens/settings/widgets/settings_tile_route.dart index 7a24ce4f..4efe3d54 100644 --- a/lib/screens/settings/widgets/settings_tile_route.dart +++ b/lib/screens/settings/widgets/settings_tile_route.dart @@ -24,7 +24,6 @@ class SettingsTileRoute extends StatelessWidget { trailing: const Icon(Icons.chevron_right_rounded), onTap: () => Navigator.of(context).push( MaterialPageRoute( - settings: RouteSettings(name: title), builder: (context) => useScaffold ? _BlurredSettingsPage(title: title, child: child) : child, @@ -70,14 +69,10 @@ class _BlurredSettingsPage extends StatelessWidget { children: [ // Main scrollable content Positioned.fill( - child: SafeArea( - top: false, - bottom: false, - child: MediaQuery.removePadding( - context: context, - removeTop: true, - child: child, - ), + child: MediaQuery.removePadding( + context: context, + removeTop: true, + child: child, ), ), // Blurred app bar overlay - positioned AFTER content so it renders on top diff --git a/lib/services/cookie_extractor.dart b/lib/services/cookie_extractor.dart deleted file mode 100644 index cca2b028..00000000 --- a/lib/services/cookie_extractor.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:flutter/services.dart'; - -class CookieExtractor { - static const _channel = MethodChannel('frosty/cookie_extractor'); - - static Future extractTwitchAuthToken() async { - return await _channel.invokeMethod('extractTwitchAuthToken'); - } -} diff --git a/lib/stores/global_assets_store.dart b/lib/stores/global_assets_store.dart index aedf7126..54e585ad 100644 --- a/lib/stores/global_assets_store.dart +++ b/lib/stores/global_assets_store.dart @@ -1,4 +1,3 @@ -import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/foundation.dart'; import 'package:frosty/apis/bttv_api.dart'; import 'package:frosty/apis/ffz_api.dart'; @@ -167,13 +166,10 @@ abstract class GlobalAssetsStoreBase with Store { required bool showFFZEmotes, required bool showFFZBadges, }) async { - void onAssetError(dynamic error, StackTrace stack) { - debugPrint('GlobalAssetsStore error: $error'); - FirebaseCrashlytics.instance.recordError( - error, - stack, - reason: 'Global asset fetch failed', - ); + // Error handler for emotes + List onEmoteError(dynamic error) { + debugPrint('GlobalAssetsStore emote error: $error'); + return []; } await Future.wait([ @@ -182,9 +178,8 @@ abstract class GlobalAssetsStoreBase with Store { twitchApi .getEmotesGlobal() .then((emotes) => _twitchGlobalEmotes = emotes) - .catchError((e, StackTrace stack) { - onAssetError(e, stack); - _twitchGlobalEmotes = []; + .catchError((e) { + _twitchGlobalEmotes = onEmoteError(e); return _twitchGlobalEmotes; }), // 7TV global emotes @@ -192,9 +187,8 @@ abstract class GlobalAssetsStoreBase with Store { sevenTVApi .getEmotesGlobal() .then((emotes) => _sevenTVGlobalEmotes = emotes) - .catchError((e, StackTrace stack) { - onAssetError(e, stack); - _sevenTVGlobalEmotes = []; + .catchError((e) { + _sevenTVGlobalEmotes = onEmoteError(e); return _sevenTVGlobalEmotes; }), // BTTV global emotes @@ -202,9 +196,8 @@ abstract class GlobalAssetsStoreBase with Store { bttvApi .getEmotesGlobal() .then((emotes) => _bttvGlobalEmotes = emotes) - .catchError((e, StackTrace stack) { - onAssetError(e, stack); - _bttvGlobalEmotes = []; + .catchError((e) { + _bttvGlobalEmotes = onEmoteError(e); return _bttvGlobalEmotes; }), // FFZ global emotes @@ -212,9 +205,8 @@ abstract class GlobalAssetsStoreBase with Store { ffzApi .getEmotesGlobal() .then((emotes) => _ffzGlobalEmotes = emotes) - .catchError((e, StackTrace stack) { - onAssetError(e, stack); - _ffzGlobalEmotes = []; + .catchError((e) { + _ffzGlobalEmotes = onEmoteError(e); return _ffzGlobalEmotes; }), @@ -223,8 +215,8 @@ abstract class GlobalAssetsStoreBase with Store { twitchApi .getBadgesGlobal() .then((badges) => _twitchGlobalBadges = badges) - .catchError((e, StackTrace stack) { - onAssetError(e, stack); + .catchError((e) { + debugPrint('GlobalAssetsStore badge error: $e'); _twitchGlobalBadges = {}; return _twitchGlobalBadges; }), @@ -232,9 +224,8 @@ abstract class GlobalAssetsStoreBase with Store { if (showBTTVBadges) bttvApi.getBadges().then((badges) => _bttvBadges = badges).catchError(( e, - StackTrace stack, ) { - onAssetError(e, stack); + debugPrint('GlobalAssetsStore badge error: $e'); _bttvBadges = {}; return _bttvBadges; }), @@ -242,9 +233,8 @@ abstract class GlobalAssetsStoreBase with Store { if (showFFZBadges) ffzApi.getBadges().then((badges) => _ffzBadges = badges).catchError(( e, - StackTrace stack, ) { - onAssetError(e, stack); + debugPrint('GlobalAssetsStore badge error: $e'); _ffzBadges = >{}; return _ffzBadges; }), diff --git a/lib/theme.dart b/lib/theme.dart index 19eb4f80..aa0ba153 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -11,90 +11,6 @@ const kOverlayShadow = [ ), ]; -/// App-specific semantic colors that extend Material's [ColorScheme]. -/// -/// Colors that map to Material 3 roles use [ColorScheme] directly: -/// - Links → [ColorScheme.primary] -/// - Mentions → [ColorScheme.error] -/// - First-time chatters → [ColorScheme.tertiary] -/// -/// Access via `Theme.of(context).extension()!` or the -/// `context.frostyColors` convenience extension. -class FrostyColors extends ThemeExtension { - final Color highlightedMessage; - final Color emoteOnly; - final Color followersOnly; - final Color slowMode; - final Color subOnly; - final Color uniqueMode; - final Color overlayOnSurface; - - const FrostyColors({ - required this.highlightedMessage, - required this.emoteOnly, - required this.followersOnly, - required this.slowMode, - required this.subOnly, - required this.uniqueMode, - required this.overlayOnSurface, - }); - - static FrostyColors light({required ColorScheme colorScheme}) => FrostyColors( - highlightedMessage: colorScheme.primary, - emoteOnly: const Color(0xFFFFB74D), - followersOnly: const Color(0xFFF44336), - slowMode: const Color(0xFF2196F3), - subOnly: const Color(0xFF4CAF50), - uniqueMode: const Color(0xFFAB47BC), - overlayOnSurface: Colors.white, - ); - - static FrostyColors dark({required ColorScheme colorScheme}) => FrostyColors( - highlightedMessage: colorScheme.primary, - emoteOnly: const Color(0xFFFFCC80), - followersOnly: const Color(0xFFE57373), - slowMode: const Color(0xFF64B5F6), - subOnly: const Color(0xFF81C784), - uniqueMode: const Color(0xFFBA68C8), - overlayOnSurface: Colors.white, - ); - - @override - FrostyColors copyWith({ - Color? highlightedMessage, - Color? emoteOnly, - Color? followersOnly, - Color? slowMode, - Color? subOnly, - Color? uniqueMode, - Color? overlayOnSurface, - }) { - return FrostyColors( - highlightedMessage: highlightedMessage ?? this.highlightedMessage, - emoteOnly: emoteOnly ?? this.emoteOnly, - followersOnly: followersOnly ?? this.followersOnly, - slowMode: slowMode ?? this.slowMode, - subOnly: subOnly ?? this.subOnly, - uniqueMode: uniqueMode ?? this.uniqueMode, - overlayOnSurface: overlayOnSurface ?? this.overlayOnSurface, - ); - } - - @override - FrostyColors lerp(FrostyColors? other, double t) { - if (other is! FrostyColors) return this; - return FrostyColors( - highlightedMessage: Color.lerp(highlightedMessage, other.highlightedMessage, t)!, - emoteOnly: Color.lerp(emoteOnly, other.emoteOnly, t)!, - followersOnly: Color.lerp(followersOnly, other.followersOnly, t)!, - slowMode: Color.lerp(slowMode, other.slowMode, t)!, - subOnly: Color.lerp(subOnly, other.subOnly, t)!, - uniqueMode: Color.lerp(uniqueMode, other.uniqueMode, t)!, - overlayOnSurface: Color.lerp(overlayOnSurface, other.overlayOnSurface, t)!, - ); - } -} - class FrostyThemes { final Color colorSchemeSeed; @@ -122,46 +38,15 @@ class FrostyThemes { const borderWidth = 0.5; return ThemeData( - extensions: [ - colorScheme.brightness == Brightness.dark - ? FrostyColors.dark(colorScheme: colorScheme) - : FrostyColors.light(colorScheme: colorScheme), - ], colorScheme: colorScheme, fontFamily: 'Inter', splashFactory: Platform.isIOS ? NoSplash.splashFactory : null, - textSelectionTheme: TextSelectionThemeData( - cursorColor: colorScheme.primary, - selectionColor: colorScheme.primary.withValues(alpha: 0.3), - selectionHandleColor: colorScheme.primary, - ), scaffoldBackgroundColor: backgroundColor, bottomSheetTheme: BottomSheetThemeData( showDragHandle: true, backgroundColor: backgroundColor, surfaceTintColor: backgroundColor, ), - menuTheme: MenuThemeData( - style: MenuStyle( - padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric(vertical: 2), - ), - shape: WidgetStatePropertyAll( - RoundedRectangleBorder( - borderRadius: const BorderRadius.all(Radius.circular(12)), - side: BorderSide(color: borderColor), - ), - ), - ), - ), - menuButtonTheme: MenuButtonThemeData( - style: MenuItemButton.styleFrom( - visualDensity: VisualDensity.compact, - minimumSize: const Size(0, 40), - padding: const EdgeInsets.symmetric(horizontal: 12), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), dialogTheme: DialogThemeData( backgroundColor: backgroundColor, surfaceTintColor: backgroundColor, @@ -181,7 +66,7 @@ class FrostyThemes { filled: true, fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.6), hintStyle: TextStyle( - color: colorScheme.onSurfaceVariant, + color: colorScheme.onSurface.withValues(alpha: 0.6), fontWeight: FontWeight.normal, ), border: OutlineInputBorder( @@ -204,23 +89,6 @@ class FrostyThemes { ), ), ), - listTileTheme: ListTileThemeData( - titleTextStyle: TextStyle( - color: colorScheme.onSurface, - fontSize: 15, - fontWeight: FontWeight.w500, - letterSpacing: -0.011, - height: 1.5, - ), - subtitleTextStyle: TextStyle( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), - fontSize: 13, - height: 1.4, - ), - leadingAndTrailingTextStyle: TextStyle( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6), - ), - ), navigationBarTheme: NavigationBarThemeData( elevation: 0, backgroundColor: backgroundColor, diff --git a/lib/utils/context_extensions.dart b/lib/utils/context_extensions.dart index f666821a..0e1ee766 100644 --- a/lib/utils/context_extensions.dart +++ b/lib/utils/context_extensions.dart @@ -3,28 +3,26 @@ 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/apis/twitch_gql_api.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/stores/global_assets_store.dart'; -import 'package:frosty/theme.dart'; import 'package:provider/provider.dart'; /// Static utility class for orientation-related operations class OrientationUtils { /// Gets the current orientation from the given context static Orientation getCurrentOrientation(BuildContext context) { - return MediaQuery.orientationOf(context); + return MediaQuery.of(context).orientation; } /// Returns true if the current orientation is portrait static bool isPortrait(BuildContext context) { - return MediaQuery.orientationOf(context) == Orientation.portrait; + return MediaQuery.of(context).orientation == Orientation.portrait; } /// Returns true if the current orientation is landscape static bool isLandscape(BuildContext context) { - return MediaQuery.orientationOf(context) == Orientation.landscape; + return MediaQuery.of(context).orientation == Orientation.landscape; } } @@ -47,34 +45,28 @@ extension ContextExtensions on BuildContext { /// Gets body small text color (commonly used for secondary text) Color? get bodySmallColor => Theme.of(this).textTheme.bodySmall?.color; - /// Gets the app's custom semantic colors - FrostyColors get frostyColors => Theme.of(this).extension()!; - /// Gets the default text style TextStyle get defaultTextStyle => DefaultTextStyle.of(this).style; // ===== SCREEN & LAYOUT ===== /// Gets the screen width - double get screenWidth => MediaQuery.sizeOf(this).width; + double get screenWidth => MediaQuery.of(this).size.width; /// Gets the screen height - double get screenHeight => MediaQuery.sizeOf(this).height; + double get screenHeight => MediaQuery.of(this).size.height; /// Gets the safe area padding top (commonly used) - double get safePaddingTop => MediaQuery.paddingOf(this).top; + double get safePaddingTop => MediaQuery.of(this).padding.top; /// Gets the safe area padding bottom (commonly used) - double get safePaddingBottom => MediaQuery.paddingOf(this).bottom; + double get safePaddingBottom => MediaQuery.of(this).padding.bottom; // ===== PROVIDER ACCESS ===== /// Gets TwitchApi from provider TwitchApi get twitchApi => read(); - /// Gets TwitchGqlApi from provider - TwitchGqlApi get twitchGqlApi => read(); - /// Gets AuthStore from provider AuthStore get authStore => read(); diff --git a/lib/utils/twitch_oauth_scopes.dart b/lib/utils/twitch_oauth_scopes.dart deleted file mode 100644 index 8e0677f9..00000000 --- a/lib/utils/twitch_oauth_scopes.dart +++ /dev/null @@ -1,94 +0,0 @@ -/// Core OAuth scopes Frosty requests on a normal Twitch login. -/// -/// Matches the pre-moderator-tools set so existing tokens remain valid without -/// a re-consent nag for non-moderators. -const twitchUserScopes = [ - 'chat:read', - 'chat:edit', - 'user:read:follows', - 'user:read:blocked_users', - 'user:manage:blocked_users', - 'user:manage:chat_color', -]; - -/// Moderator-only scopes requested via an explicit opt-in upgrade. -const twitchModeratorScopes = [ - 'user:read:moderated_channels', - 'moderator:manage:chat_messages', - 'moderator:manage:banned_users', -]; - -/// Core + moderator scopes for an opted-in moderator session. -const twitchAllUserScopes = [ - ...twitchUserScopes, - ...twitchModeratorScopes, -]; - -/// Space-separated scope string for Twitch's authorize URL (core only). -String get twitchUserScopeQuery => twitchUserScopes.join(' '); - -/// Space-separated scope string for Twitch's authorize URL (core + mod). -String get twitchAllUserScopeQuery => twitchAllUserScopes.join(' '); - -/// Human-readable labels for Frosty-requested scopes. -const twitchScopeLabels = { - 'chat:read': 'Read chat messages', - 'chat:edit': 'Send chat messages', - 'user:read:follows': 'View followed channels', - 'user:read:blocked_users': 'View blocked users', - 'user:manage:blocked_users': 'Block and unblock users', - 'user:manage:chat_color': 'Change chat name color', - 'user:read:moderated_channels': 'View channels you moderate', - 'moderator:manage:chat_messages': 'Delete chat messages as a moderator', - 'moderator:manage:banned_users': 'Timeout and ban users as a moderator', -}; - -/// Whether [granted] includes every moderator scope Frosty needs. -bool hasModeratorScopes(Iterable granted) => - missingTwitchScopes( - granted: granted, - required: twitchModeratorScopes, - ).isEmpty; - -/// Scopes in [required] that are not present in [granted]. -List missingTwitchScopes({ - required Iterable granted, - Iterable required = twitchUserScopes, -}) { - final grantedSet = granted.toSet(); - return required.where((scope) => !grantedSet.contains(scope)).toList(); -} - -String _scopeBullets(Iterable scopes) => scopes - .map((scope) => '• ${twitchScopeLabels[scope] ?? scope}') - .join('\n'); - -/// Dialog body for a Twitch Helix/OAuth 401 on a core (non-mod) request. -/// -/// Missing-permissions copy only lists **core** scopes. A token that already -/// has core scopes is treated as expired/invalid rather than "missing mod -/// permissions" so non-mods are never nagged about moderator consent. -String unauthorizedDialogMessage({ - required bool isLoggedIn, - required Iterable grantedScopes, -}) { - if (!isLoggedIn) { - return 'Your session has expired. Please log in again to continue.'; - } - - final missing = missingTwitchScopes(granted: grantedScopes); - if (missing.isEmpty) { - return 'Your session has expired. Please log in again to continue.'; - } - - return 'Frosty needs additional Twitch permissions:\n\n' - '${_scopeBullets(missing)}\n\n' - 'Log in again to grant them.'; -} - -/// Copy for the explicit moderator-tools opt-in dialog. -String enableModeratorToolsDialogMessage() { - return 'Enable moderator tools to manage chat in channels you moderate:\n\n' - '${_scopeBullets(twitchModeratorScopes)}\n\n' - 'You will be asked to authorize these permissions with Twitch.'; -} diff --git a/lib/widgets/accent_color_picker_dialog.dart b/lib/widgets/accent_color_picker_dialog.dart index 5c97b3ac..2fab7781 100644 --- a/lib/widgets/accent_color_picker_dialog.dart +++ b/lib/widgets/accent_color_picker_dialog.dart @@ -30,7 +30,7 @@ class _AccentColorPickerDialogState extends State { } void _generateRandomColor() { - HapticFeedback.lightImpact(); + HapticFeedback.mediumImpact(); final random = Random(); final newColor = Color.fromARGB( 255, diff --git a/lib/widgets/alert_message.dart b/lib/widgets/alert_message.dart index 71eadc54..0f0726c1 100644 --- a/lib/widgets/alert_message.dart +++ b/lib/widgets/alert_message.dart @@ -23,9 +23,9 @@ class AlertMessage extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final defaultColor = - vertical ? colorScheme.onSurfaceVariant : colorScheme.onSurface; + final defaultColor = Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: vertical ? 0.6 : 1); final Widget widget; diff --git a/lib/widgets/expandable_section_header.dart b/lib/widgets/expandable_section_header.dart index 1c6eb8a0..d04dd5ad 100644 --- a/lib/widgets/expandable_section_header.dart +++ b/lib/widgets/expandable_section_header.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:frosty/utils/context_extensions.dart'; class ExpandableSectionHeader extends StatelessWidget { @@ -22,20 +21,17 @@ class ExpandableSectionHeader extends StatelessWidget { @override Widget build(BuildContext context) { + final isLandscape = context.isLandscape; + return InkWell( - onTap: onToggle != null - ? () { - HapticFeedback.selectionClick(); - onToggle!(); - } - : null, + onTap: onToggle, child: Padding( padding: padding ?? EdgeInsets.fromLTRB( - 16 + MediaQuery.of(context).padding.left, + isLandscape ? MediaQuery.of(context).padding.left : 16, 0, - 16 + MediaQuery.of(context).padding.right, + isLandscape ? MediaQuery.of(context).padding.right : 16, 8, ), child: Column( @@ -49,10 +45,10 @@ class ExpandableSectionHeader extends StatelessWidget { child: Text( text.toUpperCase(), style: TextStyle( - fontSize: fontSize ?? 12, - fontWeight: FontWeight.w500, - letterSpacing: 0.8, - color: context.colorScheme.onSurfaceVariant.withValues( + fontSize: fontSize ?? 13, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + color: context.colorScheme.onSurface.withValues( alpha: 0.6, ), ), @@ -63,9 +59,7 @@ class ExpandableSectionHeader extends StatelessWidget { duration: const Duration(milliseconds: 200), child: Icon( Icons.keyboard_arrow_down, - color: context.colorScheme.onSurfaceVariant.withValues( - alpha: 0.6, - ), + color: context.colorScheme.onSurface.withValues(alpha: 0.6), size: 20, ), ), diff --git a/lib/widgets/follow_button.dart b/lib/widgets/follow_button.dart deleted file mode 100644 index c90b8c5a..00000000 --- a/lib/widgets/follow_button.dart +++ /dev/null @@ -1,206 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:frosty/apis/base_api_client.dart'; -import 'package:frosty/screens/onboarding/login_webview.dart'; -import 'package:frosty/screens/settings/stores/auth_store.dart'; -import 'package:frosty/widgets/alert_message.dart'; -import 'package:frosty/widgets/follow_channel_controller.dart'; -import 'package:frosty/widgets/frosty_dialog.dart'; -import 'package:frosty/widgets/integrity_webview_dialog.dart'; - -/// Compact Follow / Unfollow control for channel headers. -class FollowButton extends StatefulWidget { - final AuthStore authStore; - final String userId; - final String displayName; - final bool? initialIsFollowing; - final ValueChanged? onFollowChanged; - - const FollowButton({ - super.key, - required this.authStore, - required this.userId, - required this.displayName, - this.initialIsFollowing, - this.onFollowChanged, - }); - - @override - State createState() => _FollowButtonState(); -} - -class _FollowButtonState extends State { - late final FollowChannelController _controller = FollowChannelController( - authStore: widget.authStore, - userId: widget.userId, - initialIsFollowing: widget.initialIsFollowing, - onChanged: () { - if (mounted) setState(() {}); - }, - ); - - @override - void initState() { - super.initState(); - _controller.init(); - } - - Future _onPressed() async { - if (!_controller.canFollow || - _controller.isFollowing == null || - _controller.actionInProgress) { - return; - } - - final wasFollowing = _controller.isFollowing!; - - try { - final nowFollowing = await _controller.toggle(); - if (!mounted) return; - widget.onFollowChanged?.call(nowFollowing); - _showResultSnack(nowFollowing); - } on FollowNeedsWebSessionException { - if (!mounted) return; - await _promptLinkWebSession(); - } on IntegrityCheckException { - if (!mounted) return; - await _handleIntegrity(wasFollowing: wasFollowing); - } on ApiException catch (e) { - if (!mounted) return; - _showErrorSnack(e.message); - } catch (_) { - if (!mounted) return; - _showErrorSnack( - wasFollowing - ? 'Could not unfollow ${widget.displayName}' - : 'Could not follow ${widget.displayName}', - ); - } - } - - Future _handleIntegrity({required bool wasFollowing}) async { - final gqlToken = widget.authStore.gqlToken; - if (gqlToken == null) { - _controller.clearActionInProgress(); - await _promptLinkWebSession(); - return; - } - - final credentials = await showIntegrityWebViewDialog( - context: context, - gqlToken: gqlToken, - ); - if (!mounted) return; - - if (credentials == null) { - _controller.clearActionInProgress(); - _showErrorSnack('Follow cancelled — Twitch verification required'); - return; - } - - try { - final nowFollowing = await _controller.retryAfterIntegrity( - wasFollowing: wasFollowing, - credentials: credentials, - ); - if (!mounted) return; - widget.onFollowChanged?.call(nowFollowing); - _showResultSnack(nowFollowing); - } on ApiException catch (e) { - if (!mounted) return; - _showErrorSnack(e.message); - } catch (_) { - if (!mounted) return; - _showErrorSnack( - wasFollowing - ? 'Could not unfollow ${widget.displayName}' - : 'Could not follow ${widget.displayName}', - ); - } - } - - Future _promptLinkWebSession() { - return showDialog( - context: context, - builder: (dialogContext) => FrostyDialog( - title: 'Web session required', - message: - 'Following channels needs your Twitch web session linked. Log in again to link it, then try follow/unfollow.', - actions: [ - TextButton( - onPressed: Navigator.of(dialogContext).pop, - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - Navigator.of(dialogContext).pop(); - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => LoginWebView()), - ); - }, - child: const Text('Log in'), - ), - ], - ), - ); - } - - void _showResultSnack(bool nowFollowing) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: AlertMessage( - message: nowFollowing - ? 'Followed ${widget.displayName}' - : 'Unfollowed ${widget.displayName}', - centered: false, - ), - ), - ); - } - - void _showErrorSnack(String message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: AlertMessage(message: message, centered: false), - ), - ); - } - - @override - Widget build(BuildContext context) { - if (!_controller.shouldShow) { - return const SizedBox.shrink(); - } - - final following = _controller.isFollowing == true; - final busy = _controller.loading || _controller.actionInProgress; - - return FilledButton.tonal( - onPressed: busy ? null : _onPressed, - style: FilledButton.styleFrom( - visualDensity: VisualDensity.compact, - padding: const EdgeInsets.symmetric(horizontal: 12), - minimumSize: const Size(0, 36), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: busy - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Row( - mainAxisSize: MainAxisSize.min, - spacing: 6, - children: [ - Icon( - following - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, - size: 16, - ), - Text(following ? 'Unfollow' : 'Follow'), - ], - ), - ); - } -} diff --git a/lib/widgets/follow_channel_controller.dart b/lib/widgets/follow_channel_controller.dart deleted file mode 100644 index 46c45ff2..00000000 --- a/lib/widgets/follow_channel_controller.dart +++ /dev/null @@ -1,150 +0,0 @@ -import 'package:frosty/apis/base_api_client.dart'; -import 'package:frosty/apis/twitch_integrity.dart'; -import 'package:frosty/screens/settings/stores/auth_store.dart'; - -/// Shared follow/unfollow state + GQL mutations for channel UI. -/// -/// Owns loading/action flags and the session cache via [AuthStore.user]. -/// Callers handle UI (integrity WebView, login prompt, snackbars). -class FollowChannelController { - final AuthStore authStore; - final String userId; - final bool? initialIsFollowing; - final void Function() onChanged; - - bool? isFollowing; - bool loading = false; - bool actionInProgress = false; - - FollowChannelController({ - required this.authStore, - required this.userId, - this.initialIsFollowing, - required this.onChanged, - }); - - bool get isSelf => - authStore.user.details?.id != null && - authStore.user.details!.id == userId; - - bool get canFollow => authStore.isLoggedIn && !isSelf; - - /// Whether the follow control should be visible (loading or known state). - bool get shouldShow => canFollow && (loading || isFollowing != null); - - void init() { - if (!canFollow) return; - if (initialIsFollowing != null) { - isFollowing = initialIsFollowing; - onChanged(); - return; - } - loadFollowState(); - } - - Future loadFollowState() async { - final cached = authStore.user.cachedIsFollowing(userId); - if (cached != null) { - isFollowing = cached; - onChanged(); - return; - } - - loading = true; - onChanged(); - try { - final following = await authStore.user.isFollowingChannel(userId); - isFollowing = following; - } catch (_) { - // Leave isFollowing null so the control stays hidden. - } finally { - loading = false; - onChanged(); - } - } - - /// Toggles follow. Returns the new following state on success. - /// - /// Throws [IntegrityCheckException] when a WebView integrity mint is needed, - /// [ApiException] for API errors, or [StateError] when not ready / no token. - Future toggle() async { - if (actionInProgress || isFollowing == null) { - throw StateError('Follow toggle not ready'); - } - - final gqlToken = authStore.gqlToken; - if (gqlToken == null) { - throw const FollowNeedsWebSessionException(); - } - - final following = isFollowing!; - actionInProgress = true; - onChanged(); - - try { - await _runMutation(following: following, gqlToken: gqlToken); - } catch (_) { - actionInProgress = false; - onChanged(); - rethrow; - } - - final nowFollowing = !following; - isFollowing = nowFollowing; - actionInProgress = false; - onChanged(); - return nowFollowing; - } - - /// After a WebView integrity mint, apply credentials and retry [toggle]'s - /// intended mutation (still based on the pre-toggle [wasFollowing] state). - Future retryAfterIntegrity({ - required bool wasFollowing, - required GqlIntegrityCredentials credentials, - }) async { - final gqlToken = authStore.gqlToken; - if (gqlToken == null) { - throw const FollowNeedsWebSessionException(); - } - - actionInProgress = true; - onChanged(); - - try { - await authStore.user.applyWebIntegrity(credentials); - await _runMutation(following: wasFollowing, gqlToken: gqlToken); - } catch (_) { - actionInProgress = false; - onChanged(); - rethrow; - } - - final nowFollowing = !wasFollowing; - isFollowing = nowFollowing; - actionInProgress = false; - onChanged(); - return nowFollowing; - } - - Future _runMutation({ - required bool following, - required String gqlToken, - }) async { - if (following) { - await authStore.user.unfollow(targetId: userId, gqlToken: gqlToken); - } else { - await authStore.user.follow(targetId: userId, gqlToken: gqlToken); - } - } - - void clearActionInProgress() { - if (!actionInProgress) return; - actionInProgress = false; - onChanged(); - } -} - -/// Thrown when follow needs a linked Twitch web (GQL) session. -class FollowNeedsWebSessionException implements Exception { - const FollowNeedsWebSessionException(); -} diff --git a/lib/widgets/frosty_notification.dart b/lib/widgets/frosty_notification.dart index 6b1945c6..829c526c 100644 --- a/lib/widgets/frosty_notification.dart +++ b/lib/widgets/frosty_notification.dart @@ -44,7 +44,9 @@ class FrostyNotification extends StatelessWidget { icon: const Icon(Icons.close_rounded, size: 20), visualDensity: VisualDensity.compact, tooltip: 'Dismiss', - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.6), ), ], ), diff --git a/lib/widgets/frosty_page_view.dart b/lib/widgets/frosty_page_view.dart index f36e39f7..1861ec85 100644 --- a/lib/widgets/frosty_page_view.dart +++ b/lib/widgets/frosty_page_view.dart @@ -4,55 +4,17 @@ class FrostyPageView extends StatefulWidget { final List headers; final List children; - /// Actions keyed by tab index, shown trailing the tab bar - /// when that tab is selected. - final Map tabActions; - const FrostyPageView({ super.key, required this.headers, required this.children, - this.tabActions = const {}, }) : assert(headers.length == children.length); @override State createState() => _FrostyPageViewState(); } -class _FrostyPageViewState extends State - with TickerProviderStateMixin { - late TabController _tabController; - - void _handleTabChange() => setState(() {}); - - void _initTabController(int length) { - _tabController = TabController(length: length, vsync: this); - _tabController.addListener(_handleTabChange); - } - - @override - void initState() { - super.initState(); - _initTabController(widget.headers.length); - } - - @override - void didUpdateWidget(FrostyPageView oldWidget) { - super.didUpdateWidget(oldWidget); - if (_tabController.length != widget.headers.length) { - _tabController.removeListener(_handleTabChange); - _tabController.dispose(); - _initTabController(widget.headers.length); - } - } - - @override - void dispose() { - _tabController.removeListener(_handleTabChange); - _tabController.dispose(); - super.dispose(); - } - +class _FrostyPageViewState extends State { @override Widget build(BuildContext context) { return LayoutBuilder( @@ -64,39 +26,26 @@ class _FrostyPageViewState extends State return const SizedBox.expand(); } - final trailingAction = widget.tabActions[_tabController.index]; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: TabBar( - controller: _tabController, - isScrollable: true, - labelStyle: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), - labelPadding: - const EdgeInsets.symmetric(horizontal: 12), - tabs: widget.headers - .map((header) => Tab(text: header)) - .toList(), - ), + return DefaultTabController( + length: widget.headers.length, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TabBar( + isScrollable: true, + labelStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, ), - ?trailingAction, - ], - ), - const Divider(), - Expanded( - child: TabBarView( - controller: _tabController, - children: widget.children, + labelPadding: const EdgeInsets.symmetric(horizontal: 12), + tabs: widget.headers + .map((header) => Tab(text: header)) + .toList(), ), - ), - ], + const Divider(), + Expanded(child: TabBarView(children: widget.children)), + ], + ), ); }, ); diff --git a/lib/widgets/frosty_photo_view_dialog.dart b/lib/widgets/frosty_photo_view_dialog.dart index 69373f04..46a2d241 100644 --- a/lib/widgets/frosty_photo_view_dialog.dart +++ b/lib/widgets/frosty_photo_view_dialog.dart @@ -1,9 +1,10 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:frosty/cache_manager.dart'; -import 'package:frosty/utils/context_extensions.dart'; +import 'package:frosty/theme.dart'; import 'package:frosty/widgets/blurred_container.dart'; import 'package:photo_view/photo_view.dart'; +import 'package:provider/provider.dart'; class FrostyPhotoViewDialog extends StatefulWidget { final String imageUrl; @@ -179,7 +180,7 @@ class _FrostyPhotoViewDialogState extends State child: IconButton( icon: Icon( Icons.close, - color: context.frostyColors.overlayOnSurface, + color: context.watch().dark.colorScheme.onSurface, ), onPressed: Navigator.of(context).pop, ), @@ -204,7 +205,11 @@ class _FrostyPhotoViewDialogState extends State child: Text( _isFullResolution ? 'View thumbnail' : 'View original', style: TextStyle( - color: context.frostyColors.overlayOnSurface, + color: context + .watch() + .dark + .colorScheme + .onSurface, ), ), ), diff --git a/lib/widgets/integrity_webview_dialog.dart b/lib/widgets/integrity_webview_dialog.dart deleted file mode 100644 index 9d9d9054..00000000 --- a/lib/widgets/integrity_webview_dialog.dart +++ /dev/null @@ -1,232 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:frosty/apis/twitch_integrity.dart'; -import 'package:webview_flutter/webview_flutter.dart'; - -/// Captures a browser-valid `Client-Integrity` header by loading Twitch in a -/// WebView (Dio `/integrity` tokens are almost always `is_bad_bot`). -/// -/// Returns credentials, or null if the user cancels / capture times out. -Future showIntegrityWebViewDialog({ - required BuildContext context, - required String gqlToken, -}) { - return showDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) => IntegrityWebViewDialog(gqlToken: gqlToken), - ); -} - -class IntegrityWebViewDialog extends StatefulWidget { - final String gqlToken; - - const IntegrityWebViewDialog({super.key, required this.gqlToken}); - - @override - State createState() => _IntegrityWebViewDialogState(); -} - -class _IntegrityWebViewDialogState extends State { - static const _captureTimeout = Duration(seconds: 45); - - late final WebViewController _controller; - Timer? _timeout; - var _captured = false; - var _status = 'Verifying with Twitch…'; - - @override - void initState() { - super.initState(); - _controller = WebViewController() - ..setJavaScriptMode(JavaScriptMode.unrestricted) - ..setUserAgent( - Platform.isIOS - ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) ' - 'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 ' - 'Mobile/15E148 Safari/604.1' - : 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 ' - '(KHTML, like Gecko) Chrome/135.0.0.0 Mobile Safari/537.36', - ) - ..addJavaScriptChannel( - 'FrostyIntegrity', - onMessageReceived: _onIntegrityMessage, - ) - ..setNavigationDelegate( - NavigationDelegate( - onPageStarted: (_) => _injectHooks(), - onPageFinished: (_) => _injectHooks(), - ), - ); - - _prepareAndLoad(); - _timeout = Timer(_captureTimeout, () { - if (!mounted || _captured) return; - setState(() => _status = 'Timed out waiting for Twitch. Try again.'); - }); - } - - Future _prepareAndLoad() async { - try { - final cookies = WebViewCookieManager(); - await cookies.setCookie( - WebViewCookie( - name: 'auth-token', - value: widget.gqlToken, - domain: '.twitch.tv', - ), - ); - } catch (e) { - debugPrint('IntegrityWebView: cookie set failed: $e'); - } - - // Same entry as Xtra: with auth-token set, Twitch redirects and emits GQL - // requests that carry a browser-valid Client-Integrity header. - await _controller.loadRequest(Uri.parse('https://www.twitch.tv/login')); - } - - Future _injectHooks() async { - // Hook fetch + XHR so we see Client-Integrity on Twitch's own GQL traffic. - const script = r''' -(function () { - if (window.__frostyIntegrityHooked) return; - window.__frostyIntegrityHooked = true; - - function report(headers) { - if (!headers || !window.FrostyIntegrity) return; - var integrity = headers['Client-Integrity'] || headers['client-integrity']; - if (!integrity) return; - var deviceId = headers['X-Device-Id'] || headers['x-device-id'] || ''; - var clientId = headers['Client-ID'] || headers['Client-Id'] || headers['client-id'] || ''; - window.FrostyIntegrity.postMessage(JSON.stringify({ - integrity: integrity, - deviceId: deviceId, - clientId: clientId - })); - } - - function headersToObject(headers) { - var out = {}; - if (!headers) return out; - if (typeof Headers !== 'undefined' && headers instanceof Headers) { - headers.forEach(function (value, key) { out[key] = value; }); - return out; - } - if (typeof headers === 'object') { - Object.keys(headers).forEach(function (key) { out[key] = headers[key]; }); - } - return out; - } - - var originalFetch = window.fetch; - window.fetch = function (input, init) { - try { - report(headersToObject(init && init.headers)); - } catch (e) {} - return originalFetch.apply(this, arguments); - }; - - var originalOpen = XMLHttpRequest.prototype.open; - var originalSetHeader = XMLHttpRequest.prototype.setRequestHeader; - var originalSend = XMLHttpRequest.prototype.send; - XMLHttpRequest.prototype.open = function () { - this.__frostyHeaders = {}; - return originalOpen.apply(this, arguments); - }; - XMLHttpRequest.prototype.setRequestHeader = function (key, value) { - try { this.__frostyHeaders[key] = value; } catch (e) {} - return originalSetHeader.apply(this, arguments); - }; - XMLHttpRequest.prototype.send = function () { - try { report(this.__frostyHeaders || {}); } catch (e) {} - return originalSend.apply(this, arguments); - }; -})(); -'''; - - try { - await _controller.runJavaScript(script); - } catch (e) { - debugPrint('IntegrityWebView: hook inject failed: $e'); - } - } - - void _onIntegrityMessage(JavaScriptMessage message) { - if (_captured || !mounted) return; - - try { - final map = jsonDecode(message.message) as Map; - final integrity = map['integrity'] as String?; - if (integrity == null || integrity.isEmpty) return; - if (TwitchIntegrity.isBadBot(integrity)) { - debugPrint('IntegrityWebView: ignoring bad_bot token'); - return; - } - - final fromHeader = map['deviceId'] as String?; - final fromToken = TwitchIntegrity.deviceId(integrity); - final deviceId = (fromHeader != null && fromHeader.isNotEmpty) - ? fromHeader - : (fromToken ?? ''); - if (deviceId.isEmpty) { - debugPrint('IntegrityWebView: missing device id'); - return; - } - - final expiration = TwitchIntegrity.expirationMs(integrity) ?? - DateTime.now().millisecondsSinceEpoch + const Duration(hours: 16).inMilliseconds; - - _captured = true; - _timeout?.cancel(); - Navigator.of(context).pop( - GqlIntegrityCredentials( - integrityToken: integrity, - deviceId: deviceId, - expirationMs: expiration, - ), - ); - } catch (e) { - debugPrint('IntegrityWebView: parse failed: $e'); - } - } - - @override - void dispose() { - _timeout?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final height = MediaQuery.sizeOf(context).height * 0.55; - return AlertDialog( - title: const Text('Confirm with Twitch'), - content: SizedBox( - width: double.maxFinite, - height: height.clamp(280.0, 520.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text(_status), - const SizedBox(height: 12), - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: WebViewWidget(controller: _controller), - ), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - ], - ); - } -} diff --git a/lib/widgets/live_indicator.dart b/lib/widgets/live_indicator.dart index 3ef42f18..cb6ce460 100644 --- a/lib/widgets/live_indicator.dart +++ b/lib/widgets/live_indicator.dart @@ -138,61 +138,57 @@ class _LiveIndicatorState extends State ); } - return SizedBox( - width: widget.size, - height: widget.size, - child: Stack( - alignment: Alignment.center, - clipBehavior: Clip.none, - children: [ - // Ping ring in its own RepaintBoundary so its 60fps animation - // doesn't dirty the overlay's compositing layer (expensive with - // Impeller + PlatformView). - Positioned.fill( - child: RepaintBoundary( - child: AnimatedBuilder( - animation: _pingController!, - builder: (context, child) { - final pingSize = widget.size * _pingScale!.value; - return IgnorePointer( - child: OverflowBox( - maxWidth: pingSize, - maxHeight: pingSize, - child: Center( - child: Opacity( - opacity: _pingOpacity!.value, - child: Container( - width: pingSize, - height: pingSize, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: widget.color.withValues( - alpha: _borderOpacity, - ), - width: _borderWidth, + return AnimatedBuilder( + animation: _pingController!, + builder: (context, child) { + final pingSize = widget.size * _pingScale!.value; + return SizedBox( + width: widget.size, + height: widget.size, + child: Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + // Ping ring rendered outside without affecting layout + Positioned.fill( + child: IgnorePointer( + child: OverflowBox( + maxWidth: pingSize, + maxHeight: pingSize, + child: Center( + child: Opacity( + opacity: _pingOpacity!.value, + child: Container( + width: pingSize, + height: pingSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: widget.color.withValues( + alpha: _borderOpacity, ), + width: _borderWidth, ), ), ), ), ), - ); - }, + ), + ), ), - ), + // Static center dot + Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + color: widget.color, + shape: BoxShape.circle, + ), + ), + ], ), - // Static center dot - Container( - width: widget.size, - height: widget.size, - decoration: BoxDecoration( - color: widget.color, - shape: BoxShape.circle, - ), - ), - ], - ), + ); + }, ); } } diff --git a/lib/widgets/loading_indicator.dart b/lib/widgets/loading_indicator.dart index 41bdbf07..539fbab6 100644 --- a/lib/widgets/loading_indicator.dart +++ b/lib/widgets/loading_indicator.dart @@ -19,7 +19,9 @@ class LoadingIndicator extends StatelessWidget { Text( subtitle!, style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.5), ), ), ], diff --git a/lib/widgets/measure_size.dart b/lib/widgets/measure_size.dart deleted file mode 100644 index b7cba10d..00000000 --- a/lib/widgets/measure_size.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:flutter/rendering.dart'; -import 'package:flutter/widgets.dart'; - -/// Reports the rendered size of [child] via [onChange] whenever it changes. -/// -/// Unlike `LayoutBuilder` (which exposes incoming constraints, not intrinsic -/// size), this measures the child's actual laid-out size. The callback is -/// deferred to a post-frame callback so listeners can safely mutate state -/// (e.g. a store observable) without triggering a build-phase mutation. -class MeasureSize extends SingleChildRenderObjectWidget { - const MeasureSize({ - super.key, - required this.onChange, - required Widget super.child, - }); - - final ValueChanged onChange; - - @override - RenderObject createRenderObject(BuildContext context) => - _MeasureSizeRenderObject(onChange); - - @override - void updateRenderObject(BuildContext context, RenderObject renderObject) { - (renderObject as _MeasureSizeRenderObject).onChange = onChange; - } -} - -class _MeasureSizeRenderObject extends RenderProxyBox { - _MeasureSizeRenderObject(this.onChange); - - ValueChanged onChange; - Size? _oldSize; - - @override - void performLayout() { - super.performLayout(); - - final newSize = child?.size ?? Size.zero; - if (_oldSize == newSize) return; - _oldSize = newSize; - - // Defer out of the layout phase so the callback can mutate observed state. - WidgetsBinding.instance.addPostFrameCallback((_) => onChange(newSize)); - } -} diff --git a/lib/widgets/profile_picture.dart b/lib/widgets/profile_picture.dart index 8580d66a..3768428e 100644 --- a/lib/widgets/profile_picture.dart +++ b/lib/widgets/profile_picture.dart @@ -7,11 +7,7 @@ class ProfilePicture extends StatefulWidget { final String userLogin; final double radius; - const ProfilePicture({ - super.key, - required this.userLogin, - this.radius = 20, - }); + const ProfilePicture({super.key, required this.userLogin, this.radius = 20}); @override State createState() => _ProfilePictureState(); diff --git a/lib/widgets/section_header.dart b/lib/widgets/section_header.dart index 40d4855a..fbdfe47e 100644 --- a/lib/widgets/section_header.dart +++ b/lib/widgets/section_header.dart @@ -19,13 +19,15 @@ class SectionHeader extends StatelessWidget { @override Widget build(BuildContext context) { + final isLandscape = context.isLandscape; + return Padding( padding: padding ?? EdgeInsets.fromLTRB( - 16 + MediaQuery.of(context).padding.left, + isLandscape ? MediaQuery.of(context).padding.left : 16, 0, - 16 + MediaQuery.of(context).padding.right, + isLandscape ? MediaQuery.of(context).padding.right : 16, 8, ), child: Column( @@ -36,12 +38,10 @@ class SectionHeader extends StatelessWidget { Text( text.toUpperCase(), style: TextStyle( - fontSize: fontSize ?? 12, - fontWeight: FontWeight.w500, - letterSpacing: 0.8, - color: context.colorScheme.onSurfaceVariant.withValues( - alpha: 0.6, - ), + fontSize: fontSize ?? 13, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + color: context.colorScheme.onSurface.withValues(alpha: 0.6), ), ), ], diff --git a/lib/widgets/settings_page_layout.dart b/lib/widgets/settings_page_layout.dart index 67041760..50864ddb 100644 --- a/lib/widgets/settings_page_layout.dart +++ b/lib/widgets/settings_page_layout.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:frosty/utils/context_extensions.dart'; +import 'package:frosty/widgets/animated_scroll_border.dart'; import 'package:frosty/widgets/frosty_scrollbar.dart'; /// A reusable layout for settings pages that handles common functionality: /// - Orientation detection /// - Responsive padding calculations +/// - AnimatedScrollBorder positioning /// - ScrollController management class SettingsPageLayout extends StatefulWidget { final List children; @@ -39,7 +41,7 @@ class _SettingsPageLayoutState extends State { // Responsive values based on orientation final listPadding = EdgeInsets.only( - top: isPortrait ? 124 : 100, + top: isPortrait ? 116 : 92, bottom: widget.hasBottomPadding ? context.safePaddingBottom + 8 : 0, ); @@ -58,6 +60,12 @@ class _SettingsPageLayoutState extends State { children: widget.children, ), ), + Positioned( + top: borderTop.toDouble(), + left: 0, + right: 0, + child: AnimatedScrollBorder(scrollController: _scrollController), + ), ], ); diff --git a/lib/widgets/skeleton_loader.dart b/lib/widgets/skeleton_loader.dart index 75415d4e..53e6db28 100644 --- a/lib/widgets/skeleton_loader.dart +++ b/lib/widgets/skeleton_loader.dart @@ -1,7 +1,6 @@ import 'dart:math'; import 'package:flutter/material.dart'; -import 'package:frosty/constants.dart'; /// A skeleton loading widget that displays a pulsing placeholder class SkeletonLoader extends StatefulWidget { @@ -153,7 +152,7 @@ class CategorySkeletonLoader extends StatelessWidget { child: AspectRatio( aspectRatio: 3 / 4, child: const SkeletonLoader( - borderRadius: kCardBorderRadius, + borderRadius: BorderRadius.all(Radius.circular(8)), ), ), ), @@ -199,7 +198,7 @@ class StreamCardSkeletonLoader extends StatelessWidget { child: AspectRatio( aspectRatio: 16 / 9, child: const SkeletonLoader( - borderRadius: kCardBorderRadius, + borderRadius: BorderRadius.all(Radius.circular(8)), ), ), ), @@ -392,11 +391,11 @@ class LargeStreamCardSkeletonLoader extends StatelessWidget { SizedBox( width: double.infinity, child: ClipRRect( - borderRadius: kCardBorderRadius, + borderRadius: const BorderRadius.all(Radius.circular(8)), child: AspectRatio( aspectRatio: 16 / 9, child: const SkeletonLoader( - borderRadius: kCardBorderRadius, + borderRadius: BorderRadius.all(Radius.circular(8)), ), ), ), diff --git a/lib/widgets/stream_sort_button.dart b/lib/widgets/stream_sort_button.dart deleted file mode 100644 index 883fc363..00000000 --- a/lib/widgets/stream_sort_button.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:frosty/apis/twitch_gql_api.dart'; - -/// Toggles viewer-count sort between descending (default) and ascending. -class StreamSortButton extends StatelessWidget { - final StreamViewerSort sortOrder; - final ValueChanged onChanged; - - const StreamSortButton({ - super.key, - required this.sortOrder, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - final isAsc = sortOrder == StreamViewerSort.viewersAsc; - - return PopupMenuButton( - tooltip: 'Sort by viewers', - icon: Icon( - isAsc ? Icons.arrow_upward_rounded : Icons.arrow_downward_rounded, - ), - initialValue: sortOrder, - onSelected: onChanged, - itemBuilder: (context) => [ - const PopupMenuItem( - value: StreamViewerSort.viewersDesc, - child: Text('Most viewers'), - ), - const PopupMenuItem( - value: StreamViewerSort.viewersAsc, - child: Text('Fewest viewers'), - ), - ], - ); - } -} diff --git a/lib/widgets/user_actions_modal.dart b/lib/widgets/user_actions_modal.dart index 19bc1f90..099bd1c2 100644 --- a/lib/widgets/user_actions_modal.dart +++ b/lib/widgets/user_actions_modal.dart @@ -1,18 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:frosty/apis/base_api_client.dart'; -import 'package:frosty/screens/onboarding/login_webview.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; -import 'package:frosty/widgets/alert_message.dart'; import 'package:frosty/widgets/blurred_container.dart'; -import 'package:frosty/widgets/follow_channel_controller.dart'; -import 'package:frosty/widgets/frosty_dialog.dart'; -import 'package:frosty/widgets/integrity_webview_dialog.dart'; import 'package:provider/provider.dart'; import 'package:webview_flutter/webview_flutter.dart'; -class UserActionsModal extends StatefulWidget { +class UserActionsModal extends StatelessWidget { final AuthStore authStore; final String name; final String userLogin; @@ -20,16 +14,6 @@ class UserActionsModal extends StatefulWidget { final bool showPinOption; final bool? isPinned; - /// When set (e.g. Following tab), skip the Helix lookup and show this state. - final bool? initialIsFollowing; - - /// Called after a successful follow (`true`) or unfollow (`false`). - final ValueChanged? onFollowChanged; - - /// Optional moderation actions (shown at the top) for chat contexts. Kept as - /// an injected widget so this generic modal stays chat-agnostic. - final Widget? moderationActions; - const UserActionsModal({ super.key, required this.authStore, @@ -38,224 +22,49 @@ class UserActionsModal extends StatefulWidget { required this.userId, this.showPinOption = false, this.isPinned, - this.initialIsFollowing, - this.onFollowChanged, - this.moderationActions, }); - @override - State createState() => _UserActionsModalState(); -} - -class _UserActionsModalState extends State { - late final FollowChannelController _follow = FollowChannelController( - authStore: widget.authStore, - userId: widget.userId, - initialIsFollowing: widget.initialIsFollowing, - onChanged: () { - if (mounted) setState(() {}); - }, - ); - - @override - void initState() { - super.initState(); - _follow.init(); - } - - Future _toggleFollow() async { - if (!_follow.canFollow || - _follow.isFollowing == null || - _follow.actionInProgress) { - return; - } - - final wasFollowing = _follow.isFollowing!; - - try { - final nowFollowing = await _follow.toggle(); - if (!mounted) return; - widget.onFollowChanged?.call(nowFollowing); - _popWithSnack( - nowFollowing - ? 'Followed ${widget.name}' - : 'Unfollowed ${widget.name}', - ); - } on FollowNeedsWebSessionException { - if (!mounted) return; - await _promptLinkWebSession(); - } on IntegrityCheckException { - if (!mounted) return; - await _handleIntegrity(wasFollowing: wasFollowing); - } on ApiException catch (e) { - if (!mounted) return; - _showErrorSnack(e.message); - } catch (_) { - if (!mounted) return; - _showErrorSnack( - wasFollowing - ? 'Could not unfollow ${widget.name}' - : 'Could not follow ${widget.name}', - ); - } - } - - Future _handleIntegrity({required bool wasFollowing}) async { - final gqlToken = widget.authStore.gqlToken; - if (gqlToken == null) { - _follow.clearActionInProgress(); - await _promptLinkWebSession(); - return; - } - - final credentials = await showIntegrityWebViewDialog( - context: context, - gqlToken: gqlToken, - ); - if (!mounted) return; - - if (credentials == null) { - _follow.clearActionInProgress(); - _showErrorSnack('Follow cancelled — Twitch verification required'); - return; - } - - try { - final nowFollowing = await _follow.retryAfterIntegrity( - wasFollowing: wasFollowing, - credentials: credentials, - ); - if (!mounted) return; - widget.onFollowChanged?.call(nowFollowing); - _popWithSnack( - nowFollowing - ? 'Followed ${widget.name}' - : 'Unfollowed ${widget.name}', - ); - } on ApiException catch (e) { - if (!mounted) return; - _showErrorSnack(e.message); - } catch (_) { - if (!mounted) return; - _showErrorSnack( - wasFollowing - ? 'Could not unfollow ${widget.name}' - : 'Could not follow ${widget.name}', - ); - } - } - - void _popWithSnack(String message) { - final messenger = ScaffoldMessenger.maybeOf(context); - Navigator.pop(context); - messenger?.showSnackBar( - SnackBar( - content: AlertMessage(message: message, centered: false), - ), - ); - } - - void _showErrorSnack(String message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: AlertMessage(message: message, centered: false), - ), - ); - } - - Future _promptLinkWebSession() { - return showDialog( - context: context, - builder: (dialogContext) => FrostyDialog( - title: 'Web session required', - message: - 'Following channels needs your Twitch web session linked. Log in again to link it, then try follow/unfollow.', - actions: [ - TextButton( - onPressed: Navigator.of(dialogContext).pop, - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - Navigator.of(dialogContext).pop(); - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => LoginWebView()), - ); - }, - child: const Text('Log in'), - ), - ], - ), - ); - } - @override Widget build(BuildContext context) { - final showFollowTile = _follow.shouldShow; - return ListView( primary: false, shrinkWrap: true, children: [ - ?widget.moderationActions, - if (widget.showPinOption) + if (showPinOption) ListTile( leading: const Icon(Icons.push_pin_outlined), - title: Text( - '${widget.isPinned == true ? 'Unpin' : 'Pin'} ${widget.name}', - ), + title: Text('${isPinned == true ? 'Unpin' : 'Pin'} $name'), onTap: () { - if (widget.isPinned == true) { + if (isPinned == true) { context.read().pinnedChannelIds = [ ...context.read().pinnedChannelIds - ..remove(widget.userId), + ..remove(userId), ]; } else { context.read().pinnedChannelIds = [ ...context.read().pinnedChannelIds, - widget.userId, + userId, ]; } Navigator.pop(context); }, ), - if (showFollowTile) - ListTile( - leading: _follow.loading || _follow.actionInProgress - ? const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Icon( - _follow.isFollowing == true - ? Icons.heart_broken_rounded - : Icons.favorite_rounded, - ), - title: Text( - _follow.isFollowing == true - ? 'Unfollow ${widget.name}' - : 'Follow ${widget.name}', - ), - enabled: !_follow.loading && !_follow.actionInProgress, - onTap: _toggleFollow, - ), - if (widget.authStore.isLoggedIn) + if (authStore.isLoggedIn) ListTile( leading: const Icon(Icons.block_rounded), - onTap: () => widget.authStore + onTap: () => authStore .showBlockDialog( context, - targetUser: widget.name, - targetUserId: widget.userId, + targetUser: name, + targetUserId: userId, ) .then((_) { if (context.mounted) { Navigator.pop(context); } }), - title: Text('Block ${widget.name}'), + title: Text('Block $name'), ), ListTile( leading: const Icon(Icons.outlined_flag_rounded), @@ -286,7 +95,7 @@ class _UserActionsModalState extends State { icon: Icon(Icons.adaptive.arrow_back_rounded), onPressed: Navigator.of(context).pop, ), - title: Text('Report ${widget.name}'), + title: Text('Report $name'), ), body: Stack( children: [ @@ -303,7 +112,7 @@ class _UserActionsModalState extends State { ..setJavaScriptMode(JavaScriptMode.unrestricted) ..loadRequest( Uri.parse( - 'https://www.twitch.tv/${widget.userLogin}/report', + 'https://www.twitch.tv/$userLogin/report', ), ) ..setNavigationDelegate( @@ -339,7 +148,7 @@ class _UserActionsModalState extends State { }, ), ), - title: Text('Report ${widget.name}'), + title: Text('Report $name'), ), ], ); diff --git a/pubspec.lock b/pubspec.lock index b6205618..f5f34d72 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,1541 +1,1531 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c - url: "https://pub.dev" - source: hosted - version: "99.0.0" - _flutterfire_internals: - dependency: transitive - description: - name: _flutterfire_internals - sha256: "6727cf2ced9b104abca9daa278380be2eca2b98ce33d4b46f11708e387dc6b4d" - url: "https://pub.dev" - source: hosted - version: "1.3.76" - advanced_in_app_review: - dependency: "direct main" - description: - name: advanced_in_app_review - sha256: b1d34aa42a7045458364c4f114e956ed6b08efcd92cddf8bf76c51cc1e58ddd1 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7" - url: "https://pub.dev" - source: hosted - version: "12.1.0" - ansicolor: - dependency: transitive - description: - name: ansicolor - sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" - url: "https://pub.dev" - source: hosted - version: "2.0.3" - app_links: - dependency: "direct main" - description: - name: app_links - sha256: f8db46d2ea9ff6f3a37191a7fd5b7813da1253ace8c32c1b9eadace41d1188ea - url: "https://pub.dev" - source: hosted - version: "7.2.1" - app_links_linux: - dependency: transitive - description: - name: app_links_linux - sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 - url: "https://pub.dev" - source: hosted - version: "1.0.3" - app_links_platform_interface: - dependency: transitive - description: - name: app_links_platform_interface - sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809" - url: "https://pub.dev" - source: hosted - version: "2.0.4" - app_links_web: - dependency: transitive - description: - name: app_links_web - sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 - url: "https://pub.dev" - source: hosted - version: "1.0.4" - archive: - dependency: transitive - description: - name: archive - sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff - url: "https://pub.dev" - source: hosted - version: "4.0.9" - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" - async: - dependency: transitive - description: - name: async - sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 - url: "https://pub.dev" - source: hosted - version: "2.13.1" - better_native_video_player: - dependency: "direct main" - description: - path: "." - ref: frosty-patches - resolved-ref: "19c627547c227f1fc9895a5a62839e64fec689b3" - url: "https://github.com/tommyxchow/flutter-native-video-player.git" - source: git - version: "0.4.11" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - build: - dependency: transitive - description: - name: build - sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae" - url: "https://pub.dev" - source: hosted - version: "4.0.7" - build_config: - dependency: transitive - description: - name: build_config - sha256: "94eaf6708fe64408c632ef2689ca3777b112f9421306ccf4f8c84d7c5c9f83f8" - url: "https://pub.dev" - source: hosted - version: "1.3.2" - build_daemon: - dependency: transitive - description: - name: build_daemon - sha256: "79e05eaf15a48d7230b053a4363b8eaac0cc234bbd0134c3229455481f55cbc6" - url: "https://pub.dev" - source: hosted - version: "4.1.5" - build_runner: - dependency: "direct dev" - description: - name: build_runner - sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16" - url: "https://pub.dev" - source: hosted - version: "2.15.1" - built_collection: - dependency: transitive - description: - name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.dev" - source: hosted - version: "5.1.1" - built_value: - dependency: transitive - description: - name: built_value - sha256: "31b24be6615ec7fcf70b3aa5a7469fe35826485e639a16dd7eb83ba30e4cc6a8" - url: "https://pub.dev" - source: hosted - version: "8.12.7" - cached_network_image: - dependency: "direct main" - description: - name: cached_network_image - sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" - url: "https://pub.dev" - source: hosted - version: "3.4.1" - cached_network_image_platform_interface: - dependency: transitive - description: - name: cached_network_image_platform_interface - sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" - url: "https://pub.dev" - source: hosted - version: "4.1.1" - cached_network_image_web: - dependency: transitive - description: - name: cached_network_image_web - sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" - url: "https://pub.dev" - source: hosted - version: "1.3.1" - characters: - dependency: transitive - description: - name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b - url: "https://pub.dev" - source: hosted - version: "1.4.1" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" - url: "https://pub.dev" - source: hosted - version: "2.0.4" - cli_util: - dependency: transitive - description: - name: cli_util - sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c - url: "https://pub.dev" - source: hosted - version: "0.4.2" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" - source: hosted - version: "1.1.2" - code_assets: - dependency: transitive - description: - name: code_assets - sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 - url: "https://pub.dev" - source: hosted - version: "1.2.1" - collection: - dependency: "direct main" - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - crypto: - dependency: transitive - description: - name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" - source: hosted - version: "3.0.7" - csslib: - dependency: transitive - description: - name: csslib - sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" - url: "https://pub.dev" - source: hosted - version: "1.0.2" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" - url: "https://pub.dev" - source: hosted - version: "1.0.9" - dart_style: - dependency: transitive - description: - name: dart_style - sha256: a4c1ccfee44c7e75ed80484071a5c142a385345e658fd8bd7c4b5c97e7198f98 - url: "https://pub.dev" - source: hosted - version: "3.1.8" - dbus: - dependency: transitive - description: - name: dbus - sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91" - url: "https://pub.dev" - source: hosted - version: "0.7.13" - device_info_plus: - dependency: "direct main" - description: - name: device_info_plus - sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd - url: "https://pub.dev" - source: hosted - version: "12.4.0" - device_info_plus_platform_interface: - dependency: transitive - description: - name: device_info_plus_platform_interface - sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f - url: "https://pub.dev" - source: hosted - version: "7.0.3" - dio: - dependency: "direct main" - description: - name: dio - sha256: "0df44ebba85e503958eb75d07eedd3c86275a58c1d3eda2f2ce8f0a2c3abbb3c" - url: "https://pub.dev" - source: hosted - version: "5.11.0" - dio_web_adapter: - dependency: transitive - description: - name: dio_web_adapter - sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c" - url: "https://pub.dev" - source: hosted - version: "2.2.1" - dismissible_page: - dependency: transitive - description: - name: dismissible_page - sha256: "5b2316f770fe83583f770df1f6505cb19102081c5971979806e77f2e507a9958" - url: "https://pub.dev" - source: hosted - version: "1.0.2" - extended_text_field: - dependency: "direct main" - description: - path: "." - ref: "76305e9b115f0b41dc0d23e7a4f651df454fea89" - resolved-ref: "76305e9b115f0b41dc0d23e7a4f651df454fea89" - url: "https://github.com/tommyxchow/extended_text_field.git" - source: git - version: "16.0.2" - extended_text_library: - dependency: transitive - description: - name: extended_text_library - sha256: "13d99f8a10ead472d5e2cf4770d3d047203fe5054b152e9eb5dc692a71befbba" - url: "https://pub.dev" - source: hosted - version: "12.0.1" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.dev" - source: hosted - version: "1.3.3" - ffi: - dependency: transitive - description: - name: ffi - sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - file: - dependency: transitive - description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" - source: hosted - version: "7.0.1" - firebase_analytics: - dependency: "direct main" - description: - name: firebase_analytics - sha256: a139dd0ada1c6e0ffd77bfdb877ac9061ffdec7c415e3e06113bdf8844db771f - url: "https://pub.dev" - source: hosted - version: "12.4.6" - firebase_analytics_platform_interface: - dependency: transitive - description: - name: firebase_analytics_platform_interface - sha256: "448c319ea895da002e43892c7d3f15dccbc3c4c3b81d3e307e37da885ea52bdf" - url: "https://pub.dev" - source: hosted - version: "6.0.6" - firebase_analytics_web: - dependency: transitive - description: - name: firebase_analytics_web - sha256: "67f287a0df75f27eafdd4a66a41e44f232c3aba713ea4794a1159893665a8bf5" - url: "https://pub.dev" - source: hosted - version: "0.6.1+12" - firebase_core: - dependency: "direct main" - description: - name: firebase_core - sha256: "9478ca6700c02d315c6aba37e206e612317f98bb4f335bb1cbd6e0ce67dcf764" - url: "https://pub.dev" - source: hosted - version: "4.13.0" - firebase_core_platform_interface: - dependency: transitive - description: - name: firebase_core_platform_interface - sha256: e28f9afdcb5b0f0a8ea74ea3b322f5a7592c81cb45dd9d189913bdae08a2089a - url: "https://pub.dev" - source: hosted - version: "8.1.0" - firebase_core_web: - dependency: transitive - description: - name: firebase_core_web - sha256: f471a288b0101a45567548322ac4a5ad31e3ecbf87a576dcc0e424e6a11e04ea - url: "https://pub.dev" - source: hosted - version: "3.10.0" - firebase_crashlytics: - dependency: "direct main" - description: - name: firebase_crashlytics - sha256: "96c1d85de9eddc07061d3f7e2fb75596e75a45c9cec9c2e3a7cc9f97e6f3a748" - url: "https://pub.dev" - source: hosted - version: "5.2.7" - firebase_crashlytics_platform_interface: - dependency: transitive - description: - name: firebase_crashlytics_platform_interface - sha256: "47d83b71fd39c580297c696a507a7c2652680ea2045e40c6c4e3c371b0ee7bc6" - url: "https://pub.dev" - source: hosted - version: "3.8.27" - firebase_performance: - dependency: "direct main" - description: - name: firebase_performance - sha256: "570895b372791fca22080b369125fe13f3b6e1682ab0cbc204e2b46acd19b314" - url: "https://pub.dev" - source: hosted - version: "0.11.4+6" - firebase_performance_platform_interface: - dependency: transitive - description: - name: firebase_performance_platform_interface - sha256: "2979eac652173512ac07c45b8ce2ea2e37f16a7f8e4a003a3331405917886d72" - url: "https://pub.dev" - source: hosted - version: "0.2.0+6" - firebase_performance_web: - dependency: transitive - description: - name: firebase_performance_web - sha256: afb3b87ab2428fa86e4fb092c8069f1d860eb5be12ca5a57795648986093f23d - url: "https://pub.dev" - source: hosted - version: "0.1.8+12" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" - source: hosted - version: "1.1.1" - floating: - dependency: transitive - description: - name: floating - sha256: e51ce1dbcab3ea83da0ad1b07f2091b99c0e8680184c780a000567f41234d454 - url: "https://pub.dev" - source: hosted - version: "6.0.0" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_cache_manager: - dependency: "direct main" - description: - name: flutter_cache_manager - sha256: "1de7849213b4c73c85aca7e0ac687a9a5d82ccdb594366b9dcc26cb6a2189cd2" - url: "https://pub.dev" - source: hosted - version: "3.4.2" - flutter_colorpicker: - dependency: "direct main" - description: - name: flutter_colorpicker - sha256: "969de5f6f9e2a570ac660fb7b501551451ea2a1ab9e2097e89475f60e07816ea" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - flutter_launcher_icons: - dependency: "direct dev" - description: - name: flutter_launcher_icons - sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" - url: "https://pub.dev" - source: hosted - version: "0.14.4" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" - url: "https://pub.dev" - source: hosted - version: "6.0.0" - flutter_markdown_plus: - dependency: "direct main" - description: - name: flutter_markdown_plus - sha256: fce641d6c2106cc495de1cd603f97a4f9d615a97a64011928ded380dbdade935 - url: "https://pub.dev" - source: hosted - version: "1.0.12" - flutter_mobx: - dependency: "direct main" - description: - name: flutter_mobx - sha256: ba5e93467866a2991259dc51cffd41ef45f695c667c2b8e7b087bf24118b50fe - url: "https://pub.dev" - source: hosted - version: "2.3.0" - flutter_native_splash: - dependency: "direct dev" - description: - name: flutter_native_splash - sha256: "9db4b80b044e9af17cc4b1272137fc7ace0054d879ef8210a76adc34aaf4cdff" - url: "https://pub.dev" - source: hosted - version: "2.4.8" - flutter_secure_storage: - dependency: "direct main" - description: - name: flutter_secure_storage - sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" - url: "https://pub.dev" - source: hosted - version: "9.2.4" - flutter_secure_storage_linux: - dependency: transitive - description: - name: flutter_secure_storage_linux - sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 - url: "https://pub.dev" - source: hosted - version: "1.2.3" - flutter_secure_storage_macos: - dependency: transitive - description: - name: flutter_secure_storage_macos - sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" - url: "https://pub.dev" - source: hosted - version: "3.1.3" - flutter_secure_storage_platform_interface: - dependency: transitive - description: - name: flutter_secure_storage_platform_interface - sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 - url: "https://pub.dev" - source: hosted - version: "1.1.2" - flutter_secure_storage_web: - dependency: transitive - description: - name: flutter_secure_storage_web - sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 - url: "https://pub.dev" - source: hosted - version: "1.2.1" - flutter_secure_storage_windows: - dependency: transitive - description: - name: flutter_secure_storage_windows - sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - flutter_svg: - dependency: "direct main" - description: - name: flutter_svg - sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" - url: "https://pub.dev" - source: hosted - version: "2.3.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - glob: - dependency: transitive - description: - name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.dev" - source: hosted - version: "2.1.3" - graphs: - dependency: transitive - description: - name: graphs - sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - gtk: - dependency: transitive - description: - name: gtk - sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - hooks: - dependency: transitive - description: - name: hooks - sha256: "03a564c3704524ee0f7fc56fc621e8796cc2eb8c24d1f1a33b34979815b285c4" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - html: - dependency: transitive - description: - name: html - sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" - url: "https://pub.dev" - source: hosted - version: "0.15.6" - http: - dependency: transitive - description: - name: http - sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.dev" - source: hosted - version: "1.6.0" - http_mock_adapter: - dependency: "direct dev" - description: - name: http_mock_adapter - sha256: "46399c78bd4a0af071978edd8c502d7aeeed73b5fb9860bca86b5ed647a63c1b" - url: "https://pub.dev" - source: hosted - version: "0.6.1" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 - url: "https://pub.dev" - source: hosted - version: "3.2.2" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" - source: hosted - version: "4.1.2" - image: - dependency: transitive - description: - name: image - sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52" - url: "https://pub.dev" - source: hosted - version: "4.9.1" - in_app_review: - dependency: transitive - description: - name: in_app_review - sha256: "364db0c160b37fe7fad9cdaa18f968473924b17368869010af902760421b6bf8" - url: "https://pub.dev" - source: hosted - version: "2.0.12" - in_app_review_platform_interface: - dependency: transitive - description: - name: in_app_review_platform_interface - sha256: fed2c755f2125caa9ae10495a3c163aa7fab5af3585a9c62ef4a6920c5b45f10 - url: "https://pub.dev" - source: hosted - version: "2.0.5" - intl: - dependency: "direct main" - description: - name: intl - sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" - url: "https://pub.dev" - source: hosted - version: "0.20.3" - io: - dependency: transitive - description: - name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b - url: "https://pub.dev" - source: hosted - version: "1.0.5" - jni: - dependency: transitive - description: - name: jni - sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 - url: "https://pub.dev" - source: hosted - version: "1.0.3" - jni_flutter: - dependency: transitive - description: - name: jni_flutter - sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" - url: "https://pub.dev" - source: hosted - version: "1.0.2" - jni_util: - dependency: transitive - description: - name: jni_util - sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" - json_annotation: - dependency: "direct main" - description: - name: json_annotation - sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" - url: "https://pub.dev" - source: hosted - version: "4.12.0" - json_serializable: - dependency: "direct dev" - description: - name: json_serializable - sha256: e45aefa0324f08c683caafbb94b72837aa6193c61822799c916e45f4a263113d - url: "https://pub.dev" - source: hosted - version: "6.14.1" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.dev" - source: hosted - version: "11.0.2" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.dev" - source: hosted - version: "3.0.10" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - lints: - dependency: transitive - description: - name: lints - sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" - url: "https://pub.dev" - source: hosted - version: "6.1.0" - logger: - dependency: transitive - description: - name: logger - sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" - url: "https://pub.dev" - source: hosted - version: "2.7.0" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" - markdown: - dependency: transitive - description: - name: markdown - sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9 - url: "https://pub.dev" - source: hosted - version: "7.3.1" - matcher: - dependency: transitive - description: - name: matcher - sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" - url: "https://pub.dev" - source: hosted - version: "0.12.20" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" - url: "https://pub.dev" - source: hosted - version: "0.13.0" - meta: - dependency: transitive - description: - name: meta - sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" - url: "https://pub.dev" - source: hosted - version: "1.19.0" - mime: - dependency: transitive - description: - name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - mobx: - dependency: "direct main" - description: - name: mobx - sha256: "25ea9a3ee69243d578fb6288721d8eeb18550d0391c32f896d377d994154bff2" - url: "https://pub.dev" - source: hosted - version: "2.6.0" - mobx_codegen: - dependency: "direct dev" - description: - name: mobx_codegen - sha256: "6529bcae074838004b58ad01fbe4966be182a176aacfb723686985986ebac19f" - url: "https://pub.dev" - source: hosted - version: "2.7.7" - mocktail: - dependency: "direct dev" - description: - name: mocktail - sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa" - url: "https://pub.dev" - source: hosted - version: "1.0.5" - native_device_orientation: - dependency: "direct main" - description: - name: native_device_orientation - sha256: "4751bd8648a1e4a22c2cf3e8035eb0d667f8f6d82f9544b2f7b1bc12de42f0a4" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - nested: - dependency: transitive - description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - objective_c: - dependency: transitive - description: - name: objective_c - sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e - url: "https://pub.dev" - source: hosted - version: "9.5.0" - octo_image: - dependency: transitive - description: - name: octo_image - sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - package_config: - dependency: transitive - description: - name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc - url: "https://pub.dev" - source: hosted - version: "2.2.0" - package_info_plus: - dependency: "direct main" - description: - name: package_info_plus - sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" - url: "https://pub.dev" - source: hosted - version: "9.0.1" - package_info_plus_platform_interface: - dependency: transitive - description: - name: package_info_plus_platform_interface - sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" - url: "https://pub.dev" - source: hosted - version: "3.2.1" - path: - dependency: transitive - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - path_parsing: - dependency: transitive - description: - name: path_parsing - sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - path_provider: - dependency: "direct main" - description: - name: path_provider - sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 - url: "https://pub.dev" - source: hosted - version: "2.1.6" - path_provider_android: - dependency: transitive - description: - name: path_provider_android - sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" - url: "https://pub.dev" - source: hosted - version: "2.3.1" - path_provider_foundation: - dependency: transitive - description: - name: path_provider_foundation - sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" - url: "https://pub.dev" - source: hosted - version: "2.6.0" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" - url: "https://pub.dev" - source: hosted - version: "2.2.2" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" - url: "https://pub.dev" - source: hosted - version: "2.1.3" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" - source: hosted - version: "2.3.0" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" - url: "https://pub.dev" - source: hosted - version: "7.0.2" - photo_view: - dependency: "direct main" - description: - name: photo_view - sha256: "1fc3d970a91295fbd1364296575f854c9863f225505c28c46e0a03e48960c75e" - url: "https://pub.dev" - source: hosted - version: "0.15.0" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" - source: hosted - version: "3.1.6" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - pool: - dependency: transitive - description: - name: pool - sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" - url: "https://pub.dev" - source: hosted - version: "1.5.2" - posix: - dependency: transitive - description: - name: posix - sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e - url: "https://pub.dev" - source: hosted - version: "6.5.2" - provider: - dependency: "direct main" - description: - name: provider - sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" - url: "https://pub.dev" - source: hosted - version: "6.1.5+1" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - pubspec_parse: - dependency: transitive - description: - name: pubspec_parse - sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" - url: "https://pub.dev" - source: hosted - version: "1.5.0" - record_use: - dependency: transitive - description: - name: record_use - sha256: "3b4ab682aff40175afca6ff090cc5aa7ce9dafe8dfbae1537a6c678e6678baee" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - rxdart: - dependency: transitive - description: - name: rxdart - sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" - url: "https://pub.dev" - source: hosted - version: "0.28.0" - shared_preferences: - dependency: "direct main" - description: - name: shared_preferences - sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf - url: "https://pub.dev" - source: hosted - version: "2.5.5" - shared_preferences_android: - dependency: transitive - description: - name: shared_preferences_android - sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" - url: "https://pub.dev" - source: hosted - version: "2.4.27" - shared_preferences_foundation: - dependency: transitive - description: - name: shared_preferences_foundation - sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" - url: "https://pub.dev" - source: hosted - version: "2.5.6" - shared_preferences_linux: - dependency: transitive - description: - name: shared_preferences_linux - sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shared_preferences_platform_interface: - dependency: transitive - description: - name: shared_preferences_platform_interface - sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" - url: "https://pub.dev" - source: hosted - version: "2.4.2" - shared_preferences_web: - dependency: transitive - description: - name: shared_preferences_web - sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 - url: "https://pub.dev" - source: hosted - version: "2.4.3" - shared_preferences_windows: - dependency: transitive - description: - name: shared_preferences_windows - sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shelf: - dependency: transitive - description: - name: shelf - sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 - url: "https://pub.dev" - source: hosted - version: "1.4.2" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" - url: "https://pub.dev" - source: hosted - version: "3.0.0" - simple_icons: - dependency: "direct main" - description: - name: simple_icons - sha256: "21246ffcaaf9f560695596093c2ecf9fa763fd112a15916fbe7d282932430bae" - url: "https://pub.dev" - source: hosted - version: "16.23.0" - simple_pip_mode: - dependency: "direct main" - description: - path: "." - ref: HEAD - resolved-ref: "0da5255545e10b18883c737c9eca3ec619ccc2f6" - url: "https://github.com/tommyxchow/simple_pip_mode_flutter" - source: git - version: "1.0.0" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - source_gen: - dependency: transitive - description: - name: source_gen - sha256: a603f1fb984a7391ae5978d1b92bfaaa08b350dca5c825256f925818f7943bf5 - url: "https://pub.dev" - source: hosted - version: "4.2.4" - source_helper: - dependency: transitive - description: - name: source_helper - sha256: "5e6f216fdf6376c9f3852381ae037499797a3385377d388b011dac98d303c67c" - url: "https://pub.dev" - source: hosted - version: "1.3.13" - source_span: - dependency: transitive - description: - name: source_span - sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" - url: "https://pub.dev" - source: hosted - version: "1.10.2" - sqflite: - dependency: transitive - description: - name: sqflite - sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082" - url: "https://pub.dev" - source: hosted - version: "2.4.3" - sqflite_android: - dependency: transitive - description: - name: sqflite_android - sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b - url: "https://pub.dev" - source: hosted - version: "2.4.3" - sqflite_common: - dependency: transitive - description: - name: sqflite_common - sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590" - url: "https://pub.dev" - source: hosted - version: "2.5.11" - sqflite_darwin: - dependency: transitive - description: - name: sqflite_darwin - sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f - url: "https://pub.dev" - source: hosted - version: "2.4.3+1" - sqflite_platform_interface: - dependency: transitive - description: - name: sqflite_platform_interface - sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50 - url: "https://pub.dev" - source: hosted - version: "2.4.1" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" - source: hosted - version: "1.12.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.dev" - source: hosted - version: "2.1.1" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" - source: hosted - version: "1.4.1" - synchronized: - dependency: transitive - description: - name: synchronized - sha256: "3a7b5d17422dd0f8d5c6c14feaa5a1c65638b9455f871a96f08437562c046931" - url: "https://pub.dev" - source: hosted - version: "3.4.1+2" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" - source: hosted - version: "1.2.2" - test_api: - dependency: transitive - description: - name: test_api - sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" - url: "https://pub.dev" - source: hosted - version: "0.7.12" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - universal_io: - dependency: transitive - description: - name: universal_io - sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 - url: "https://pub.dev" - source: hosted - version: "2.3.1" - url_launcher: - dependency: "direct main" - description: - name: url_launcher - sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 - url: "https://pub.dev" - source: hosted - version: "6.3.2" - url_launcher_android: - dependency: transitive - description: - name: url_launcher_android - sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 - url: "https://pub.dev" - source: hosted - version: "6.3.32" - url_launcher_ios: - dependency: transitive - description: - name: url_launcher_ios - sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" - url: "https://pub.dev" - source: hosted - version: "6.4.1" - url_launcher_linux: - dependency: transitive - description: - name: url_launcher_linux - sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a - url: "https://pub.dev" - source: hosted - version: "3.2.2" - url_launcher_macos: - dependency: transitive - description: - name: url_launcher_macos - sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" - url: "https://pub.dev" - source: hosted - version: "3.2.5" - url_launcher_platform_interface: - dependency: transitive - description: - name: url_launcher_platform_interface - sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - url_launcher_web: - dependency: transitive - description: - name: url_launcher_web - sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" - url: "https://pub.dev" - source: hosted - version: "2.4.3" - url_launcher_windows: - dependency: transitive - description: - name: url_launcher_windows - sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" - url: "https://pub.dev" - source: hosted - version: "3.1.5" - uuid: - dependency: transitive - description: - name: uuid - sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" - url: "https://pub.dev" - source: hosted - version: "4.6.0" - vector_graphics: - dependency: transitive - description: - name: vector_graphics - sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935" - url: "https://pub.dev" - source: hosted - version: "1.2.3" - vector_graphics_codec: - dependency: transitive - description: - name: vector_graphics_codec - sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" - url: "https://pub.dev" - source: hosted - version: "1.1.13" - vector_graphics_compiler: - dependency: transitive - description: - name: vector_graphics_compiler - sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682" - url: "https://pub.dev" - source: hosted - version: "1.3.0" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 - url: "https://pub.dev" - source: hosted - version: "2.4.2" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" - url: "https://pub.dev" - source: hosted - version: "15.2.0" - wakelock_plus: - dependency: "direct main" - description: - name: wakelock_plus - sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1 - url: "https://pub.dev" - source: hosted - version: "1.5.2" - wakelock_plus_platform_interface: - dependency: transitive - description: - name: wakelock_plus_platform_interface - sha256: "0618d1799f0b28bcf98255b4ee8313e6fc4d38589dc4ee5fe5840d57d1aff6da" - url: "https://pub.dev" - source: hosted - version: "1.6.0" - watcher: - dependency: transitive - description: - name: watcher - sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" - url: "https://pub.dev" - source: hosted - version: "1.2.1" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: "direct main" - description: - name: web_socket_channel - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.dev" - source: hosted - version: "3.0.3" - webview_flutter: - dependency: "direct main" - description: - name: webview_flutter - sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111 - url: "https://pub.dev" - source: hosted - version: "4.14.1" - webview_flutter_android: - dependency: "direct main" - description: - name: webview_flutter_android - sha256: b98656fa4461f8cc05c48a778b4d4883e60ec63e1778348f363f9bb9a477745d - url: "https://pub.dev" - source: hosted - version: "4.14.0" - webview_flutter_platform_interface: - dependency: transitive - description: - name: webview_flutter_platform_interface - sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" - url: "https://pub.dev" - source: hosted - version: "2.15.1" - webview_flutter_wkwebview: - dependency: "direct main" - description: - name: webview_flutter_wkwebview - sha256: c879dd64b87c452aa84381b244d5469da57ba7e8cca6884c7b1e0d406372c12d - url: "https://pub.dev" - source: hosted - version: "3.26.0" - win32: - dependency: transitive - description: - name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e - url: "https://pub.dev" - source: hosted - version: "5.15.0" - win32_registry: - dependency: transitive - description: - name: win32_registry - sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - xml: - dependency: transitive - description: - name: xml - sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" - url: "https://pub.dev" - source: hosted - version: "7.0.1" - yaml: - dependency: transitive - description: - name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" - source: hosted - version: "3.1.3" -sdks: - dart: ">=3.12.0 <4.0.0" - flutter: "3.47.0" +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "5b7468c326d2f8a4f630056404ca0d291ade42918f4a3c6233618e724f39da8e" + url: "https://pub.dev" + source: hosted + version: "92.0.0" + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: cd83f7d6bd4e4c0b0b4fef802e8796784032e1cc23d7b0e982cf5d05d9bbe182 + url: "https://pub.dev" + source: hosted + version: "1.3.66" + advanced_in_app_review: + dependency: "direct main" + description: + name: advanced_in_app_review + sha256: "06075938ce94fc6cc67e7b60b503504e329e5d7cb9900dea9768dba0fa89bded" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "70e4b1ef8003c64793a9e268a551a82869a8a96f39deb73dea28084b0e8bf75e" + url: "https://pub.dev" + source: hosted + version: "9.0.0" + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + app_links: + dependency: "direct main" + description: + name: app_links + sha256: "3462d9defc61565fde4944858b59bec5be2b9d5b05f20aed190adb3ad08a7abc" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3" + url: "https://pub.dev" + source: hosted + version: "4.0.4" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: ac78098de97893812b7aff1154f29008fa2464cad9e8e7044d39bc905dad4fbc + url: "https://pub.dev" + source: hosted + version: "2.11.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "7931c90b84bc573fef103548e354258ae4c9d28d140e41961df6843c5d60d4d8" + url: "https://pub.dev" + source: hosted + version: "8.12.3" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" + collection: + dependency: "direct main" + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b + url: "https://pub.dev" + source: hosted + version: "3.1.3" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c" + url: "https://pub.dev" + source: hosted + version: "12.3.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" + dio: + dependency: "direct main" + description: + name: dio + sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25 + url: "https://pub.dev" + source: hosted + version: "5.9.1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + extended_text_field: + dependency: "direct main" + description: + name: extended_text_field + sha256: "3996195c117c6beb734026a7bc0ba80d7e4e84e4edd4728caa544d8209ab4d7d" + url: "https://pub.dev" + source: hosted + version: "16.0.2" + extended_text_library: + dependency: transitive + description: + name: extended_text_library + sha256: "13d99f8a10ead472d5e2cf4770d3d047203fe5054b152e9eb5dc692a71befbba" + url: "https://pub.dev" + source: hosted + version: "12.0.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c + url: "https://pub.dev" + source: hosted + version: "2.1.5" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + firebase_analytics: + dependency: "direct main" + description: + name: firebase_analytics + sha256: "91e2739bad690da2826c0cd5b28328fd15fb87adf54634cded703f34fd797a81" + url: "https://pub.dev" + source: hosted + version: "12.1.1" + firebase_analytics_platform_interface: + dependency: transitive + description: + name: firebase_analytics_platform_interface + sha256: "62fd3f27f342c898bd819fb97fa87c0b971e9fbe03357477282c0e14e1a40c3c" + url: "https://pub.dev" + source: hosted + version: "5.0.6" + firebase_analytics_web: + dependency: transitive + description: + name: firebase_analytics_web + sha256: "8fc488bb008439fc3b850cfac892dec1ff4cd438eee44438919a14c5e61b9828" + url: "https://pub.dev" + source: hosted + version: "0.6.1+2" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "923085c881663ef685269b013e241b428e1fb03cdd0ebde265d9b40ff18abf80" + url: "https://pub.dev" + source: hosted + version: "4.4.0" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: "83e7356c704131ca4d8d8dd57e360d8acecbca38b1a3705c7ae46cc34c708084" + url: "https://pub.dev" + source: hosted + version: "3.4.0" + firebase_crashlytics: + dependency: "direct main" + description: + name: firebase_crashlytics + sha256: a6e6cb8b2ea1214533a54e4c1b11b19c40f6a29333f3ab0854a479fdc3237c5b + url: "https://pub.dev" + source: hosted + version: "5.0.7" + firebase_crashlytics_platform_interface: + dependency: transitive + description: + name: firebase_crashlytics_platform_interface + sha256: fc6837c4c64c48fa94cab8a872a632b9194fa9208ca76a822f424b3da945584d + url: "https://pub.dev" + source: hosted + version: "3.8.17" + firebase_performance: + dependency: "direct main" + description: + name: firebase_performance + sha256: "72b8800907e2f7a0c65c140324a6eb07b9e16a73874b032ce7e5cc79e6dac4e9" + url: "https://pub.dev" + source: hosted + version: "0.11.1+4" + firebase_performance_platform_interface: + dependency: transitive + description: + name: firebase_performance_platform_interface + sha256: "6f59cb2fdcf5e42c709ff0ca3f74cae0b656a1c288c508c40da71929fca6f4f5" + url: "https://pub.dev" + source: hosted + version: "0.1.6+4" + firebase_performance_web: + dependency: transitive + description: + name: firebase_performance_web + sha256: "1ee6d1f27f340f39adb3d8afdce579ead1ff6a3df424503d4ea272e0498f33b6" + url: "https://pub.dev" + source: hosted + version: "0.1.8+2" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: "direct main" + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + flutter_colorpicker: + dependency: "direct main" + description: + name: flutter_colorpicker + sha256: "969de5f6f9e2a570ac660fb7b501551451ea2a1ab9e2097e89475f60e07816ea" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_markdown_plus: + dependency: "direct main" + description: + name: flutter_markdown_plus + sha256: "039177906850278e8fb1cd364115ee0a46281135932fa8ecea8455522166d2de" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + flutter_mobx: + dependency: "direct main" + description: + name: flutter_mobx + sha256: ba5e93467866a2991259dc51cffd41ef45f695c667c2b8e7b087bf24118b50fe + url: "https://pub.dev" + source: hosted + version: "2.3.0" + flutter_native_splash: + dependency: "direct dev" + description: + name: flutter_native_splash + sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002" + url: "https://pub.dev" + source: hosted + version: "2.4.7" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + gtk: + dependency: transitive + description: + name: gtk + sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c + url: "https://pub.dev" + source: hosted + version: "2.1.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_mock_adapter: + dependency: "direct dev" + description: + name: http_mock_adapter + sha256: "46399c78bd4a0af071978edd8c502d7aeeed73b5fb9860bca86b5ed647a63c1b" + url: "https://pub.dev" + source: hosted + version: "0.6.1" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" + url: "https://pub.dev" + source: hosted + version: "4.7.2" + in_app_review: + dependency: transitive + description: + name: in_app_review + sha256: ab26ac54dbd802896af78c670b265eaeab7ecddd6af4d0751e9604b60574817f + url: "https://pub.dev" + source: hosted + version: "2.0.11" + in_app_review_platform_interface: + dependency: transitive + description: + name: in_app_review_platform_interface + sha256: fed2c755f2125caa9ae10495a3c163aa7fab5af3585a9c62ef4a6920c5b45f10 + url: "https://pub.dev" + source: hosted + version: "2.0.5" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df" + url: "https://pub.dev" + source: hosted + version: "4.10.0" + json_schema: + dependency: transitive + description: + name: json_schema + sha256: f37d9c3fdfe8c9aae55fdfd5af815d24ce63c3a0f6a2c1f0982c30f43643fa1a + url: "https://pub.dev" + source: hosted + version: "5.2.2" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: "93fba3ad139dab2b1ce59ecc6fdce6da46a42cdb6c4399ecda30f1e7e725760d" + url: "https://pub.dev" + source: hosted + version: "6.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logger: + dependency: transitive + description: + name: logger + sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + url: "https://pub.dev" + source: hosted + version: "2.6.2" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + url: "https://pub.dev" + source: hosted + version: "7.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mobx: + dependency: "direct main" + description: + name: mobx + sha256: "25ea9a3ee69243d578fb6288721d8eeb18550d0391c32f896d377d994154bff2" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + mobx_codegen: + dependency: "direct dev" + description: + name: mobx_codegen + sha256: "406051a76157fb0a562700110bd2bedd85c959b1cd2d3df740c706bc07539c33" + url: "https://pub.dev" + source: hosted + version: "2.7.6" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + native_device_orientation: + dependency: "direct main" + description: + name: native_device_orientation + sha256: "711aabfd7c67396f6562437cba078d17291f8b7c454c2fbc9739c9d7141b041d" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + url: "https://pub.dev" + source: hosted + version: "0.17.4" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d + url: "https://pub.dev" + source: hosted + version: "9.0.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + photo_view: + dependency: "direct main" + description: + name: photo_view + sha256: "1fc3d970a91295fbd1364296575f854c9863f225505c28c46e0a03e48960c75e" + url: "https://pub.dev" + source: hosted + version: "0.15.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + quiver: + dependency: transitive + description: + name: quiver + sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + rfc_6901: + dependency: transitive + description: + name: rfc_6901 + sha256: "6a43b1858dca2febaf93e15639aa6b0c49ccdfd7647775f15a499f872b018154" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f + url: "https://pub.dev" + source: hosted + version: "2.4.20" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + simple_icons: + dependency: "direct main" + description: + name: simple_icons + sha256: "2ca3cd79c9f12e97a8588cae0f342609f19fd2e82315356cb09b5c4987ad0808" + url: "https://pub.dev" + source: hosted + version: "14.6.1" + simple_pip_mode: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: "0da5255545e10b18883c737c9eca3ec619ccc2f6" + url: "https://github.com/tommyxchow/simple_pip_mode_flutter" + source: git + version: "1.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17" + url: "https://pub.dev" + source: hosted + version: "4.2.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "4a85e90b50694e652075cbe4575665539d253e6ec10e46e76b45368ab5e3caae" + url: "https://pub.dev" + source: hosted + version: "1.3.10" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 + url: "https://pub.dev" + source: hosted + version: "2.4.2+2" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + url: "https://pub.dev" + source: hosted + version: "0.7.7" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + url: "https://pub.dev" + source: hosted + version: "2.3.1" + uri: + dependency: transitive + description: + name: uri + sha256: "889eea21e953187c6099802b7b4cf5219ba8f3518f604a1033064d45b1b8268a" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + url: "https://pub.dev" + source: hosted + version: "6.3.6" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "201e876b5d52753626af64b6359cd13ac6011b80728731428fd34bc840f71c9b" + url: "https://pub.dev" + source: hosted + version: "1.1.20" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + wakelock_plus: + dependency: "direct main" + description: + name: wakelock_plus + sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: "direct main" + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webview_flutter: + dependency: "direct main" + description: + name: webview_flutter + sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9 + url: "https://pub.dev" + source: hosted + version: "4.13.1" + webview_flutter_android: + dependency: "direct main" + description: + name: webview_flutter_android + sha256: eeeb3fcd5f0ff9f8446c9f4bbc18a99b809e40297528a3395597d03aafb9f510 + url: "https://pub.dev" + source: hosted + version: "4.10.11" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0" + url: "https://pub.dev" + source: hosted + version: "2.14.0" + webview_flutter_wkwebview: + dependency: "direct main" + description: + name: webview_flutter_wkwebview + sha256: "0412b657a2828fb301e73509909e6ec02b77cd2b441ae9f77125d482b3ddf0e7" + url: "https://pub.dev" + source: hosted + version: "3.23.6" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 1899d872..3be38265 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,271 +1,250 @@ -name: frosty -description: A Twitch client for iOS and Android. - -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: "none" # Remove this line if you wish to publish to pub.dev - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. -version: 5.3.1+81 - -environment: - sdk: ^3.9.0 - # Exact Flutter pin, enforced by `flutter pub get` locally and read by CI via - # `flutter-version-file: pubspec.yaml`. Single source of truth: bump here. - flutter: 3.47.0 - -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. -dependencies: - flutter: - sdk: flutter - json_annotation: ^4.12.0 - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.2 - web_socket_channel: ^3.0.0 - # Pinned to v9 deliberately. v10.0.0 was shipped in 5.0.0 and reverted in - # 5.0.1 (ba28e09) while chasing the #484 startup freeze on low-end Android - # 14; that commit also made deleteAll() non-blocking, so which change fixed - # it was never established. v10 rewrote the Android ciphers, and v11 drops - # the legacy ones entirely, so a 9 -> 11 jump would also strand existing - # tokens. Move to v10 on its own, with low-end Android testing. - flutter_secure_storage: ^9.2.2 - intl: ^0.20.1 - cached_network_image: ^3.1.0 - webview_flutter: ^4.2.0 - provider: ^6.0.1 - mobx: ^2.0.7+4 - flutter_mobx: ^2.0.0 - shared_preferences: ^2.0.8 - flutter_svg: ^2.0.5 - url_launcher: ^6.0.17 - # Held at v9/v12: the v10/v13 majors are just a win32 6.0.0 bump (Windows - # only), which conflicts with flutter_secure_storage v9's win32 ^5. - package_info_plus: ^9.0.0 - collection: ^1.16.0 - flutter_cache_manager: ^3.3.0 - device_info_plus: ^12.3.0 - wakelock_plus: ^1.1.2 - simple_icons: ^16.23.0 - simple_pip_mode: - git: - url: https://github.com/tommyxchow/simple_pip_mode_flutter - firebase_core: ^4.1.0 - firebase_crashlytics: ^5.0.1 - firebase_performance: ^0.11.0+1 - firebase_analytics: ^12.0.1 - advanced_in_app_review: ^1.1.3 - webview_flutter_android: ^4.0.2 - webview_flutter_wkwebview: ^3.4.3 - photo_view: ^0.15.0 - flutter_markdown_plus: ^1.0.7 - flutter_colorpicker: ^1.1.0 - path_provider: ^2.1.5 - dio: ^5.9.0 - app_links: ^7.0.0 - # Fork pinned for Flutter 3.44 compatibility — upstream 16.0.2 is the latest - # pub release and references ExtendSelectionByPageIntent, removed in 3.44. - extended_text_field: - git: - url: https://github.com/tommyxchow/extended_text_field.git - ref: 76305e9b115f0b41dc0d23e7a4f651df454fea89 - native_device_orientation: ^2.1.0 - better_native_video_player: - git: - url: https://github.com/tommyxchow/flutter-native-video-player.git - ref: frosty-patches - -dev_dependencies: - flutter_test: - sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. - flutter_lints: ^6.0.0 - build_runner: ^2.1.1 - json_serializable: ^6.0.0 - mobx_codegen: ^2.0.6 - flutter_native_splash: ^2.0.5 - flutter_launcher_icons: ^0.14.1 - mocktail: ^1.0.4 - http_mock_adapter: ^0.6.1 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. - uses-material-design: true - - fonts: - - family: Inter - fonts: - - asset: assets/fonts/Inter-Thin.ttf - - asset: assets/fonts/Inter-ExtraLight.ttf - - asset: assets/fonts/Inter-Light.ttf - - asset: assets/fonts/Inter-Regular.ttf - - asset: assets/fonts/Inter-Medium.ttf - - asset: assets/fonts/Inter-SemiBold.ttf - - asset: assets/fonts/Inter-Bold.ttf - - asset: assets/fonts/Inter-ExtraBold.ttf - - asset: assets/fonts/Inter-Black.ttf - - asset: assets/fonts/Inter-ThinItalic.ttf - - asset: assets/fonts/Inter-ExtraLightItalic.ttf - - asset: assets/fonts/Inter-LightItalic.ttf - - asset: assets/fonts/Inter-Italic.ttf - - asset: assets/fonts/Inter-MediumItalic.ttf - - asset: assets/fonts/Inter-SemiBoldItalic.ttf - - asset: assets/fonts/Inter-BoldItalic.ttf - - asset: assets/fonts/Inter-ExtraBoldItalic.ttf - - asset: assets/fonts/Inter-BlackItalic.ttf - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - assets: - - assets/icons/logo.svg - - assets/fonts/ - - assets/release-notes.md - - assets/js/vaft.user.js - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package - -flutter_native_splash: - # This package generates native code to customize Flutter's default white native splash screen - # with background color and splash image. - # Customize the parameters below, and run the following command in the terminal: - # flutter pub run flutter_native_splash:create - # To restore Flutter's default white splash screen, run the following command in the terminal: - # flutter pub run flutter_native_splash:remove - - # color or background_image is the only required parameter. Use color to set the background - # of your splash screen to a solid color. Use background_image to set the background of your - # splash screen to a png image. This is useful for gradients. The image will be stretch to the - # size of the app. Only one parameter can be used, color and background_image cannot both be set. - # color: "#000000" - background_image: "assets/icons/bg.png" - - # Optional parameters are listed below. To enable a parameter, uncomment the line by removing - # the leading # character. - - # The image parameter allows you to specify an image used in the splash screen. It must be a - # png file and should be sized for 4x pixel density. - image: assets/icons/logo.png - - # This property allows you to specify an image used as branding in the splash screen. It must be - # a png file. Currently, it is only supported for Android and iOS. - # branding: assets/dart.png - - # Specify your branding image for dark mode. - #branding_dark: assets/dart_dark.png - - # To position the branding image at the bottom of the screen you can use bottom, bottomRight, - # and bottomLeft. The default values is bottom if not specified or specified something else. - # - # Make sure this content mode value should not be similar to android_gravity value and ios_content_mode - # value. - # branding_mode: bottom - - # The color_dark, background_image_dark, and image_dark are parameters that set the background - # and image when the device is in dark mode. If they are not specified, the app will use the - # parameters from above. If the image_dark parameter is specified, color_dark or - # background_image_dark must be specified. color_dark and background_image_dark cannot both be - # set. - #color_dark: "#042a49" - #background_image_dark: "assets/dark-background.png" - #image_dark: assets/splash-invert.png - - # The android, ios and web parameters can be used to disable generating a splash screen on a given - # platform. - #android: false - #ios: false - #web: false - - # The position of the splash image can be set with android_gravity, ios_content_mode, and - # web_image_mode parameters. All default to center. - # - # android_gravity can be one of the following Android Gravity (see - # https://developer.android.com/reference/android/view/Gravity): bottom, center, - # center_horizontal, center_vertical, clip_horizontal, clip_vertical, end, fill, fill_horizontal, - # fill_vertical, left, right, start, or top. - #android_gravity: center - # - # ios_content_mode can be one of the following iOS UIView.ContentMode (see - # https://developer.apple.com/documentation/uikit/uiview/contentmode): scaleToFill, - # scaleAspectFit, scaleAspectFill, center, top, bottom, left, right, topLeft, topRight, - # bottomLeft, or bottomRight. - #ios_content_mode: center - # - # web_image_mode can be one of the following modes: center, contain, stretch, and cover. - #web_image_mode: center - - # To hide the notification bar, use the fullscreen parameter. Has no affect in web since web - # has no notification bar. Defaults to false. - # NOTE: Unlike Android, iOS will not automatically show the notification bar when the app loads. - # To show the notification bar, add the following code to your Flutter app: - # WidgetsFlutterBinding.ensureInitialized(); - # SystemChrome.setEnabledSystemUIOverlays([SystemUiOverlay.bottom, SystemUiOverlay.top]); - #fullscreen: true - - # If you have changed the name(s) of your info.plist file(s), you can specify the filename(s) - # with the info_plist_files parameter. Remove only the # characters in the three lines below, - # do not remove any spaces: - #info_plist_files: - # - 'ios/Runner/Info-Debug.plist' - # - 'ios/Runner/Info-Release.plist' - -flutter_launcher_icons: - image_path_android: "assets/icons/icon_android.png" - image_path_ios: "assets/icons/icon_ios.png" - android: true - ios: true - remove_alpha_ios: true +name: frosty +description: A Twitch client for iOS and Android. + +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: "none" # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 5.0.5+68 + +environment: + sdk: ^3.9.0 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + json_annotation: ^4.6.0 + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.2 + web_socket_channel: ^3.0.0 + flutter_secure_storage: ^9.2.2 + intl: ^0.20.1 + cached_network_image: ^3.1.0 + webview_flutter: ^4.2.0 + provider: ^6.0.1 + mobx: ^2.0.7+4 + flutter_mobx: ^2.0.0 + shared_preferences: ^2.0.8 + flutter_svg: ^2.0.5 + url_launcher: ^6.0.17 + package_info_plus: ^9.0.0 + collection: ^1.16.0 + flutter_cache_manager: ^3.3.0 + device_info_plus: ^12.3.0 + wakelock_plus: ^1.1.2 + simple_icons: ^14.6.1 + simple_pip_mode: + git: + url: https://github.com/tommyxchow/simple_pip_mode_flutter + firebase_core: ^4.1.0 + firebase_crashlytics: ^5.0.1 + firebase_performance: ^0.11.0+1 + firebase_analytics: ^12.0.1 + advanced_in_app_review: ^1.1.3 + webview_flutter_android: ^4.0.2 + webview_flutter_wkwebview: ^3.4.3 + photo_view: ^0.15.0 + flutter_markdown_plus: ^1.0.7 + flutter_colorpicker: ^1.1.0 + path_provider: ^2.1.5 + dio: ^5.9.0 + app_links: ^7.0.0 + extended_text_field: ^16.0.2 + native_device_orientation: ^2.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + build_runner: ^2.1.1 + json_serializable: ^6.0.0 + mobx_codegen: ^2.0.6 + flutter_native_splash: ^2.0.5 + flutter_launcher_icons: ^0.14.1 + mocktail: ^1.0.4 + http_mock_adapter: ^0.6.1 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + fonts: + - family: Inter + fonts: + - asset: assets/fonts/Inter-Thin.ttf + - asset: assets/fonts/Inter-ExtraLight.ttf + - asset: assets/fonts/Inter-Light.ttf + - asset: assets/fonts/Inter-Regular.ttf + - asset: assets/fonts/Inter-Medium.ttf + - asset: assets/fonts/Inter-SemiBold.ttf + - asset: assets/fonts/Inter-Bold.ttf + - asset: assets/fonts/Inter-ExtraBold.ttf + - asset: assets/fonts/Inter-Black.ttf + - asset: assets/fonts/Inter-ThinItalic.ttf + - asset: assets/fonts/Inter-ExtraLightItalic.ttf + - asset: assets/fonts/Inter-LightItalic.ttf + - asset: assets/fonts/Inter-Italic.ttf + - asset: assets/fonts/Inter-MediumItalic.ttf + - asset: assets/fonts/Inter-SemiBoldItalic.ttf + - asset: assets/fonts/Inter-BoldItalic.ttf + - asset: assets/fonts/Inter-ExtraBoldItalic.ttf + - asset: assets/fonts/Inter-BlackItalic.ttf + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + assets: + - assets/icons/logo.svg + - assets/fonts/ + - assets/release-notes.md + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package + +flutter_native_splash: + # This package generates native code to customize Flutter's default white native splash screen + # with background color and splash image. + # Customize the parameters below, and run the following command in the terminal: + # flutter pub run flutter_native_splash:create + # To restore Flutter's default white splash screen, run the following command in the terminal: + # flutter pub run flutter_native_splash:remove + + # color or background_image is the only required parameter. Use color to set the background + # of your splash screen to a solid color. Use background_image to set the background of your + # splash screen to a png image. This is useful for gradients. The image will be stretch to the + # size of the app. Only one parameter can be used, color and background_image cannot both be set. + # color: "#000000" + background_image: "assets/icons/bg.png" + + # Optional parameters are listed below. To enable a parameter, uncomment the line by removing + # the leading # character. + + # The image parameter allows you to specify an image used in the splash screen. It must be a + # png file and should be sized for 4x pixel density. + image: assets/icons/logo.png + + # This property allows you to specify an image used as branding in the splash screen. It must be + # a png file. Currently, it is only supported for Android and iOS. + # branding: assets/dart.png + + # Specify your branding image for dark mode. + #branding_dark: assets/dart_dark.png + + # To position the branding image at the bottom of the screen you can use bottom, bottomRight, + # and bottomLeft. The default values is bottom if not specified or specified something else. + # + # Make sure this content mode value should not be similar to android_gravity value and ios_content_mode + # value. + # branding_mode: bottom + + # The color_dark, background_image_dark, and image_dark are parameters that set the background + # and image when the device is in dark mode. If they are not specified, the app will use the + # parameters from above. If the image_dark parameter is specified, color_dark or + # background_image_dark must be specified. color_dark and background_image_dark cannot both be + # set. + #color_dark: "#042a49" + #background_image_dark: "assets/dark-background.png" + #image_dark: assets/splash-invert.png + + # The android, ios and web parameters can be used to disable generating a splash screen on a given + # platform. + #android: false + #ios: false + #web: false + + # The position of the splash image can be set with android_gravity, ios_content_mode, and + # web_image_mode parameters. All default to center. + # + # android_gravity can be one of the following Android Gravity (see + # https://developer.android.com/reference/android/view/Gravity): bottom, center, + # center_horizontal, center_vertical, clip_horizontal, clip_vertical, end, fill, fill_horizontal, + # fill_vertical, left, right, start, or top. + #android_gravity: center + # + # ios_content_mode can be one of the following iOS UIView.ContentMode (see + # https://developer.apple.com/documentation/uikit/uiview/contentmode): scaleToFill, + # scaleAspectFit, scaleAspectFill, center, top, bottom, left, right, topLeft, topRight, + # bottomLeft, or bottomRight. + #ios_content_mode: center + # + # web_image_mode can be one of the following modes: center, contain, stretch, and cover. + #web_image_mode: center + + # To hide the notification bar, use the fullscreen parameter. Has no affect in web since web + # has no notification bar. Defaults to false. + # NOTE: Unlike Android, iOS will not automatically show the notification bar when the app loads. + # To show the notification bar, add the following code to your Flutter app: + # WidgetsFlutterBinding.ensureInitialized(); + # SystemChrome.setEnabledSystemUIOverlays([SystemUiOverlay.bottom, SystemUiOverlay.top]); + #fullscreen: true + + # If you have changed the name(s) of your info.plist file(s), you can specify the filename(s) + # with the info_plist_files parameter. Remove only the # characters in the three lines below, + # do not remove any spaces: + #info_plist_files: + # - 'ios/Runner/Info-Debug.plist' + # - 'ios/Runner/Info-Release.plist' + +flutter_launcher_icons: + image_path_android: "assets/icons/icon_android.png" + image_path_ios: "assets/icons/icon_ios.png" + android: true + ios: true + remove_alpha_ios: true diff --git a/scripts/get_twitch_token.dart b/scripts/get_twitch_token.dart deleted file mode 100644 index 22630424..00000000 --- a/scripts/get_twitch_token.dart +++ /dev/null @@ -1,126 +0,0 @@ -// Gets a Twitch OAuth user token for emulator testing. -// -// Usage: dart run scripts/get_twitch_token.dart -// -// Reads CLIENT_ID from .env, env vars, or prompts interactively. -// Opens the Twitch OAuth page in your browser — after you authorize, -// paste the redirect URL back here and the token is printed. -// Then long-press the Anonymous account tile in the app to log in. -import 'dart:io'; - -import 'package:frosty/utils/twitch_oauth_scopes.dart'; - -const redirectUri = 'https://twitch.tv/login'; - -void main() async { - final env = _loadEnvFile(); - - final clientId = _resolve('CLIENT_ID', env); - - if (clientId == null) { - stderr.writeln('Error: CLIENT_ID is required.'); - stderr.writeln('Add it to .env or export it in your shell.'); - exit(1); - } - - final authorizeUrl = Uri( - scheme: 'https', - host: 'id.twitch.tv', - path: '/oauth2/authorize', - queryParameters: { - 'client_id': clientId, - 'redirect_uri': redirectUri, - 'response_type': 'token', - // Core scopes only — matches normal app login. Use the in-app - // "Enable moderator tools" flow when moderator scopes are needed. - 'scope': twitchUserScopeQuery, - }, - ).toString(); - - stdout.writeln('Opening Twitch authorization in your browser...'); - await _openUrl(authorizeUrl); - - stdout.writeln(); - stdout.writeln( - 'After authorizing, copy the full URL from the address bar and paste it here:', - ); - stdout.writeln( - '(it will look like https://twitch.tv/login#access_token=...)', - ); - stdout.writeln(); - stdout.write('> '); - - final redirectUrl = stdin.readLineSync()?.trim(); - if (redirectUrl == null || redirectUrl.isEmpty) { - stderr.writeln('No URL provided.'); - exit(1); - } - - // The token is in the URL fragment (#access_token=...). - // Replace # with ? so Uri can parse it as query parameters. - final uri = Uri.tryParse(redirectUrl.replaceFirst('#', '?')); - final token = uri?.queryParameters['access_token']; - - if (token == null || token.isEmpty) { - stderr.writeln(); - stderr.writeln('Error: could not extract token from that URL.'); - stderr.writeln( - 'Make sure you pasted the full URL including the #access_token=... part.', - ); - exit(1); - } - - stdout.writeln(); - stdout.writeln('Token: $token'); - stdout.writeln(); - stdout.writeln( - 'Now in the emulator, long-press the Anonymous account tile to log in.', - ); -} - -/// Reads a value from env vars first, then .env file, then prompts. -String? _resolve(String name, Map envFile) { - final fromEnv = Platform.environment[name]; - if (fromEnv != null && fromEnv.isNotEmpty) return fromEnv; - - final fromFile = envFile[name]; - if (fromFile != null && fromFile.isNotEmpty) return fromFile; - - stdout.write('$name: '); - return stdin.readLineSync()?.trim(); -} - -/// Parses the .env file next to this script's project root. -Map _loadEnvFile() { - final scriptDir = File(Platform.script.toFilePath()).parent; - final projectDir = scriptDir.parent; - final envFile = File('${projectDir.path}/.env'); - - if (!envFile.existsSync()) return {}; - - final result = {}; - for (final line in envFile.readAsLinesSync()) { - final trimmed = line.trim(); - if (trimmed.isEmpty || trimmed.startsWith('#')) continue; - final eq = trimmed.indexOf('='); - if (eq < 0) continue; - result[trimmed.substring(0, eq)] = trimmed.substring(eq + 1); - } - return result; -} - -/// Opens a URL in the default browser across platforms. -Future _openUrl(String url) async { - final ProcessResult result; - if (Platform.isWindows) { - result = await Process.run('cmd', ['/c', 'start', '', url]); - } else if (Platform.isMacOS) { - result = await Process.run('open', [url]); - } else { - result = await Process.run('xdg-open', [url]); - } - if (result.exitCode != 0) { - stderr.writeln('Could not open browser. Visit this URL manually:'); - stderr.writeln(url); - } -} diff --git a/scripts/setup-forgejo-release-secrets.sh b/scripts/setup-forgejo-release-secrets.sh deleted file mode 100755 index 325bb5a1..00000000 --- a/scripts/setup-forgejo-release-secrets.sh +++ /dev/null @@ -1,235 +0,0 @@ -#!/usr/bin/env bash -# Generate Android release signing material + upload Forgejo Actions secrets for -# Obtainium APK builds — without printing secret values. -# -# Usage (from anywhere): -# bash /home/yuna/git/frosty/scripts/setup-forgejo-release-secrets.sh -# -# What it does: -# 1. Writes secrets to ~/.config/frosty/forgejo-release-secrets.env (mode 600) -# — generates a new upload keystore + passwords if the file is missing -# — merges CLIENT_ID / SECRET from frosty/.env when present (never echoes them) -# 2. PUTs each secret to Forgejo Actions on copilot/frosty -# -# Re-run safely: existing secrets file is reused (keystore not regenerated) unless -# FORCE_REGENERATE=1 bash …/setup-forgejo-release-secrets.sh -# -# Twitch Helix: if CLIENT_ID/SECRET are still empty after merge, the script stops -# before upload and tells you to edit the secrets file (add the two lines) then re-run. -set -euo pipefail - -REPO="${REPO:-copilot/frosty}" -FORGEJO_URL="${FORGEJO_URL:-https://forgejo.fsociety00.cc}" -TOKEN_FILE="${TOKEN_FILE:-${HOME}/.config/forgejo/token}" -SECRETS_DIR="${SECRETS_DIR:-${HOME}/.config/frosty}" -SECRETS_FILE="${SECRETS_FILE:-${SECRETS_DIR}/forgejo-release-secrets.env}" -KEYSTORE_FILE="${KEYSTORE_FILE:-${SECRETS_DIR}/upload-keystore.jks}" -FROSTY_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -ENV_FILE="${ENV_FILE:-${FROSTY_ROOT}/.env}" -KEY_ALIAS_DEFAULT="${KEY_ALIAS_DEFAULT:-upload}" - -need_cmd() { - command -v "$1" >/dev/null 2>&1 || { - echo "ERROR: missing command: $1" >&2 - exit 1 - } -} - -need_cmd curl -need_cmd python3 -need_cmd openssl -need_cmd keytool -need_cmd base64 - -if [[ ! -f "$TOKEN_FILE" ]]; then - echo "ERROR: Forgejo token not found: $TOKEN_FILE" >&2 - exit 1 -fi -TOKEN="$(tr -d '[:space:]' <"$TOKEN_FILE")" -[[ -n "$TOKEN" ]] || { - echo "ERROR: empty token in $TOKEN_FILE" >&2 - exit 1 -} - -mkdir -p "$SECRETS_DIR" -chmod 700 "$SECRETS_DIR" - -rand_pw() { - # URL/shell-safe password, no quotes - openssl rand -base64 24 | tr -d '\n/+=\r' | head -c 32 -} - -write_secrets_file() { - local store_pw key_pw alias b64 - store_pw="$1" - key_pw="$2" - alias="$3" - b64="$4" - # Atomic write; never echo values - local tmp - tmp="$(mktemp "${SECRETS_DIR}/.secrets.XXXXXX")" - umask 077 - cat >"$tmp" <&2 - # Response may contain error text only — still avoid dumping request body - python3 -c 'import pathlib; p=pathlib.Path("/tmp/frosty-forgejo-secret-resp.json"); print(p.read_text()[:500] if p.exists() else "")' >&2 || true - exit 1 - fi - echo " set ${name} (HTTP ${code})" -} - -# --- generate if needed --- -if [[ ! -f "$SECRETS_FILE" || "${FORCE_REGENERATE:-0}" == "1" ]]; then - echo "→ Generating new upload keystore + passwords → ${SECRETS_FILE}" - STORE_PW="$(rand_pw)" - KEY_PW="$(rand_pw)" - ALIAS="$KEY_ALIAS_DEFAULT" - rm -f "$KEYSTORE_FILE" - keytool -genkeypair \ - -keystore "$KEYSTORE_FILE" \ - -storetype JKS \ - -storepass "$STORE_PW" \ - -keypass "$KEY_PW" \ - -alias "$ALIAS" \ - -keyalg RSA \ - -keysize 2048 \ - -validity 10000 \ - -dname "CN=Frosty Forgejo Release, OU=Newas, O=fsociety00, L=Home, ST=NA, C=DE" \ - >/dev/null 2>&1 - chmod 600 "$KEYSTORE_FILE" - B64="$(base64 -w0 "$KEYSTORE_FILE" 2>/dev/null || base64 "$KEYSTORE_FILE" | tr -d '\n')" - write_secrets_file "$STORE_PW" "$KEY_PW" "$ALIAS" "$B64" - # Drop locals - unset STORE_PW KEY_PW B64 - echo " wrote keystore ${KEYSTORE_FILE}" - echo " wrote secrets file (mode 600)" -else - echo "→ Reusing existing secrets file: ${SECRETS_FILE}" - echo " (set FORCE_REGENERATE=1 to mint a new keystore)" -fi - -echo "→ Merging Twitch CLIENT_ID/SECRET from ${ENV_FILE} if present…" -merge_twitch_from_dotenv || true - -if ! status="$(secrets_complete)"; then - echo >&2 - echo "Twitch Helix secrets still missing in ${SECRETS_FILE}." >&2 - echo "Edit that file and set CLIENT_ID=… and SECRET=… (from https://dev.twitch.tv/console)," >&2 - echo "or put them in ${ENV_FILE}, then re-run this script." >&2 - echo "Signing secrets are already generated; they will be uploaded together." >&2 - exit 3 -fi -echo "→ Secrets file complete (${status})" - -echo "→ Uploading Actions secrets to ${FORGEJO_URL}/${REPO} (values not printed)" -for name in RELEASE_KEYSTORE_BASE64 STORE_PASSWORD KEY_ALIAS KEY_PASSWORD CLIENT_ID SECRET; do - put_secret "$name" -done - -echo -echo "Done. Secrets live only in:" -echo " ${SECRETS_FILE}" -echo " ${KEYSTORE_FILE}" -echo " Forgejo Actions → ${REPO}" -echo -echo "Next: Forgejo → Actions → Release APK → Run workflow" -echo "Obtainium: https://forgejo.fsociety00.cc/api/v1/repos/copilot/frosty/releases" -echo "APK filter: frosty-arm64-v8a-v.*\\.apk" diff --git a/test/apis/base_api_client_test.dart b/test/apis/base_api_client_test.dart index f0170b7d..258d68dd 100644 --- a/test/apis/base_api_client_test.dart +++ b/test/apis/base_api_client_test.dart @@ -331,41 +331,6 @@ void main() { ), ); }); - - const timeoutCases = { - DioExceptionType.connectionTimeout: 'Connection timeout', - DioExceptionType.sendTimeout: 'Upload timeout', - DioExceptionType.receiveTimeout: 'Server response timeout', - DioExceptionType.transformTimeout: 'Response processing timeout', - }; - - timeoutCases.forEach((type, expectedMessage) { - test('${type.name} throws TimeoutException', () async { - final path = '/${type.name}'; - - dioAdapter.onGet( - 'https://api.test.com$path', - (server) => server.throws( - 0, - DioException( - requestOptions: RequestOptions(path: path), - type: type, - ), - ), - ); - - expect( - () => client.testGet(path), - throwsA( - isA().having( - (e) => e.message, - 'message', - contains(expectedMessage), - ), - ), - ); - }); - }); }); group('successful get()', () { diff --git a/test/apis/twitch_ad_solutions_test.dart b/test/apis/twitch_ad_solutions_test.dart deleted file mode 100644 index 44103bc6..00000000 --- a/test/apis/twitch_ad_solutions_test.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:frosty/apis/twitch_ad_solutions.dart'; - -void main() { - group('TwitchAdSolutions', () { - test('playlistContainsAds detects stitched signifier', () { - expect( - TwitchAdSolutions.playlistContainsAds( - '#EXTM3U\n#EXT-X-DATERANGE:ID="stitched-ad-1"\n', - ), - isTrue, - ); - expect( - TwitchAdSolutions.playlistContainsAds('#EXTM3U\n#EXTINF:2.0,\nlive.ts\n'), - isFalse, - ); - }); - - test('playlistIsMidroll detects midroll markers', () { - expect( - TwitchAdSolutions.playlistIsMidroll('X-TV-TWITCH-AD-POD-TYPE="MIDROLL"'), - isTrue, - ); - expect(TwitchAdSolutions.playlistIsMidroll('live only'), isFalse); - }); - - test('platformForPlayerType matches vaft', () { - expect(TwitchAdSolutions.platformForPlayerType('autoplay'), 'android'); - expect(TwitchAdSolutions.platformForPlayerType('popout'), 'web'); - expect(TwitchAdSolutions.platformForPlayerType('embed'), 'web'); - }); - - test('force / backup player types match vaft defaults', () { - expect(TwitchAdSolutions.forceAccessTokenPlayerType, 'popout'); - expect(TwitchAdSolutions.backupPlayerTypes, [ - 'embed', - 'popout', - 'autoplay', - ]); - expect(TwitchAdSolutions.fallbackPlayerType, 'embed'); - }); - }); - - group('TwitchAdFreePlayback.firstMediaPlaylistUrl', () { - test('resolves relative media playlist against base', () { - const master = ''' -#EXTM3U -#EXT-X-STREAM-INF:BANDWIDTH=1000,RESOLUTION=640x360 -360p.m3u8 -'''; - final url = TwitchAdFreePlayback.firstMediaPlaylistUrl( - master, - baseUrl: 'https://usher.ttvnw.net/api/v2/channel/hls/foo.m3u8?x=1', - ); - expect( - url, - 'https://usher.ttvnw.net/api/v2/channel/hls/360p.m3u8', - ); - }); - - test('keeps absolute media playlist URLs', () { - const master = ''' -#EXTM3U -#EXT-X-STREAM-INF:BANDWIDTH=1000 -https://video-weaver.example/v1/playlist/abc.m3u8 -'''; - final url = TwitchAdFreePlayback.firstMediaPlaylistUrl( - master, - baseUrl: 'https://usher.ttvnw.net/foo.m3u8', - ); - expect(url, 'https://video-weaver.example/v1/playlist/abc.m3u8'); - }); - - test('returns null when no media URI present', () { - expect( - TwitchAdFreePlayback.firstMediaPlaylistUrl( - '#EXTM3U\n', - baseUrl: 'https://example.com/x.m3u8', - ), - isNull, - ); - }); - }); -} diff --git a/test/apis/twitch_api_test.dart b/test/apis/twitch_api_test.dart index 4180a464..5b5df81a 100644 --- a/test/apis/twitch_api_test.dart +++ b/test/apis/twitch_api_test.dart @@ -215,27 +215,26 @@ void main() { }); group('validateToken', () { - test('returns scopes and user id for valid token', () async { + test('returns true for valid token', () async { dioAdapter.onGet( 'https://id.twitch.tv/oauth2/validate', (server) => server.reply(200, twitchValidateTokenResponse), ); - final info = await api.validateToken(token: 'valid_token'); + final isValid = await api.validateToken(token: 'valid_token'); - expect(info?.scopes, ['chat:read', 'chat:edit']); - expect(info?.userId, '12345'); + expect(isValid, isTrue); }); - test('returns null for expired token (401)', () async { + test('returns false for expired token (401)', () async { dioAdapter.onGet( 'https://id.twitch.tv/oauth2/validate', (server) => server.reply(401, {'message': 'invalid access token'}), ); - final info = await api.validateToken(token: 'expired_token'); + final isValid = await api.validateToken(token: 'expired_token'); - expect(info, isNull); + expect(isValid, isFalse); }); test('rethrows on server error', () { @@ -291,70 +290,6 @@ void main() { }); }); - group('getModeratedChannels', () { - test('returns broadcaster IDs from a single page', () async { - dioAdapter.onGet( - 'https://api.twitch.tv/helix/moderation/channels', - (server) => server.reply(200, { - 'data': [ - {'broadcaster_id': '111', 'broadcaster_login': 'a'}, - {'broadcaster_id': '222', 'broadcaster_login': 'b'}, - ], - 'pagination': {'cursor': null}, - }), - queryParameters: {'user_id': '12345', 'first': '100'}, - ); - - final channels = await api.getModeratedChannels(id: '12345'); - - expect(channels, ['111', '222']); - }); - - test('follows the pagination cursor across pages', () async { - dioAdapter - ..onGet( - 'https://api.twitch.tv/helix/moderation/channels', - (server) => server.reply(200, { - 'data': [ - {'broadcaster_id': '111'}, - ], - 'pagination': {'cursor': 'page2'}, - }), - queryParameters: {'user_id': '12345', 'first': '100'}, - ) - ..onGet( - 'https://api.twitch.tv/helix/moderation/channels', - (server) => server.reply(200, { - 'data': [ - {'broadcaster_id': '222'}, - ], - 'pagination': {'cursor': null}, - }), - queryParameters: { - 'user_id': '12345', - 'first': '100', - 'after': 'page2', - }, - ); - - final channels = await api.getModeratedChannels(id: '12345'); - - expect(channels, ['111', '222']); - }); - - test('returns an empty list when the scope is missing', () async { - dioAdapter.onGet( - 'https://api.twitch.tv/helix/moderation/channels', - (server) => server.reply(401, {'message': 'Missing scope'}), - queryParameters: {'user_id': '12345', 'first': '100'}, - ); - - final channels = await api.getModeratedChannels(id: '12345'); - - expect(channels, isEmpty); - }); - }); - group('getTopStreams', () { test('returns streams with pagination', () async { dioAdapter.onGet( @@ -454,188 +389,4 @@ void main() { expect(emotes[2].type, EmoteType.twitchChannel); }); }); - - group('banUser', () { - test('returns true on success', () async { - dioAdapter.onPost( - 'https://api.twitch.tv/helix/moderation/bans', - (server) => server.reply(200, { - 'data': [ - {'user_id': '3'}, - ], - }), - data: { - 'data': {'user_id': '3'}, - }, - queryParameters: {'broadcaster_id': '1', 'moderator_id': '2'}, - ); - - final result = await api.banUser( - broadcasterId: '1', - moderatorId: '2', - userIdToBan: '3', - ); - - expect(result, isTrue); - }); - - test('propagates the Twitch message when the target cannot be banned ' - '(e.g. another moderator)', () async { - dioAdapter.onPost( - 'https://api.twitch.tv/helix/moderation/bans', - (server) => server.reply(400, { - 'message': 'The user specified in the user_id field is a moderator.', - }), - data: { - 'data': {'user_id': '3'}, - }, - queryParameters: {'broadcaster_id': '1', 'moderator_id': '2'}, - ); - - expect( - () => api.banUser( - broadcasterId: '1', - moderatorId: '2', - userIdToBan: '3', - ), - throwsA( - isA().having( - (e) => e.message, - 'message', - 'The user specified in the user_id field is a moderator.', - ), - ), - ); - }); - }); - - group('deleteChatMessage', () { - test('propagates the Twitch message when the message cannot be deleted ' - "(e.g. another moderator's or the broadcaster's)", () async { - dioAdapter.onDelete( - 'https://api.twitch.tv/helix/moderation/chat', - (server) => server.reply(400, { - 'message': "You cannot delete the broadcaster's messages.", - }), - queryParameters: { - 'broadcaster_id': '1', - 'moderator_id': '2', - 'message_id': 'm', - }, - ); - - expect( - () => api.deleteChatMessage( - broadcasterId: '1', - moderatorId: '2', - messageId: 'm', - ), - throwsA( - isA().having( - (e) => e.message, - 'message', - "You cannot delete the broadcaster's messages.", - ), - ), - ); - }); - }); - - group('unbanUser', () { - test('returns true on success (removes a ban or timeout)', () async { - dioAdapter.onDelete( - 'https://api.twitch.tv/helix/moderation/bans', - (server) => server.reply(204, null), - queryParameters: { - 'broadcaster_id': '1', - 'moderator_id': '2', - 'user_id': '3', - }, - ); - - final result = await api.unbanUser( - broadcasterId: '1', - moderatorId: '2', - userId: '3', - ); - - expect(result, isTrue); - }); - - test('propagates the Twitch message when the user is not banned', () async { - dioAdapter.onDelete( - 'https://api.twitch.tv/helix/moderation/bans', - (server) => server.reply(400, { - 'message': 'The user in the user_id query parameter is not banned.', - }), - queryParameters: { - 'broadcaster_id': '1', - 'moderator_id': '2', - 'user_id': '3', - }, - ); - - expect( - () => api.unbanUser(broadcasterId: '1', moderatorId: '2', userId: '3'), - throwsA( - isA().having( - (e) => e.message, - 'message', - 'The user in the user_id query parameter is not banned.', - ), - ), - ); - }); - }); - - group('isFollowingChannel', () { - test('returns true when Helix lists the broadcaster', () async { - dioAdapter.onGet( - 'https://api.twitch.tv/helix/channels/followed', - (server) => server.reply(200, { - 'total': 1, - 'data': [ - { - 'broadcaster_id': '999', - 'broadcaster_login': 'streamer', - 'broadcaster_name': 'Streamer', - 'followed_at': '2024-01-01T00:00:00Z', - }, - ], - 'pagination': {}, - }), - queryParameters: { - 'user_id': '12345', - 'broadcaster_id': '999', - 'first': '1', - }, - ); - - expect( - await api.isFollowingChannel(userId: '12345', broadcasterId: '999'), - isTrue, - ); - }); - - test('returns false when not following', () async { - dioAdapter.onGet( - 'https://api.twitch.tv/helix/channels/followed', - (server) => server.reply(200, { - 'total': 0, - 'data': [], - 'pagination': {}, - }), - queryParameters: { - 'user_id': '12345', - 'broadcaster_id': '999', - 'first': '1', - }, - ); - - expect( - await api.isFollowingChannel(userId: '12345', broadcasterId: '999'), - isFalse, - ); - }); - }); } diff --git a/test/apis/twitch_gql_follow_test.dart b/test/apis/twitch_gql_follow_test.dart deleted file mode 100644 index 00efa120..00000000 --- a/test/apis/twitch_gql_follow_test.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'dart:convert'; - -import 'package:dio/dio.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:frosty/apis/base_api_client.dart'; -import 'package:frosty/apis/twitch_gql_api.dart'; -import 'package:http_mock_adapter/http_mock_adapter.dart'; - -void main() { - late Dio dio; - late DioAdapter dioAdapter; - late TwitchGqlApi api; - - setUp(() { - dio = Dio(BaseOptions()); - dioAdapter = DioAdapter(dio: dio); - api = TwitchGqlApi(dio); - }); - - group('followUser', () { - test('posts FollowButton_FollowUser persisted mutation', () async { - dioAdapter.onPost( - 'https://gql.twitch.tv/gql', - (server) => server.reply(200, { - 'data': { - 'followUser': { - 'follow': { - 'user': {'id': '999', 'displayName': 'Streamer'}, - }, - }, - }, - }), - data: Matchers.any, - ); - - await api.followUser(targetId: '999', authToken: 'web-token'); - }); - - test('throws IntegrityCheckException on integrity errors', () async { - dioAdapter.onPost( - 'https://gql.twitch.tv/gql', - (server) => server.reply(200, { - 'errors': [ - {'message': 'failed integrity check'}, - ], - }), - data: Matchers.any, - ); - - await expectLater( - api.followUser(targetId: '999', authToken: 'web-token'), - throwsA(isA()), - ); - }); - }); - - group('unfollowUser', () { - test('posts FollowButton_UnfollowUser persisted mutation', () async { - dioAdapter.onPost( - 'https://gql.twitch.tv/gql', - (server) => server.reply(200, { - 'data': { - 'unfollowUser': { - 'follow': null, - }, - }, - }), - data: Matchers.any, - ); - - await api.unfollowUser(targetId: '999', authToken: 'web-token'); - }); - }); - - group('fetchIntegrityToken', () { - test('rejects bad_bot tokens', () async { - final payload = base64Url.encode( - utf8.encode( - jsonEncode({ - 'is_bad_bot': 'true', - 'device_id': 'dev', - 'client_id': 'kimne78kx3ncx6brgo4mv6wki5h1ko', - }), - ), - ); - - dioAdapter.onPost( - 'https://gql.twitch.tv/integrity', - (server) => server.reply(200, { - 'token': 'v4.public.$payload.sig', - 'expiration': DateTime.now().millisecondsSinceEpoch + 600000, - }), - data: Matchers.any, - ); - - await expectLater( - api.fetchIntegrityToken(authToken: 'web', deviceId: 'dev'), - throwsA(isA()), - ); - }); - }); -} diff --git a/test/apis/twitch_gql_streams_test.dart b/test/apis/twitch_gql_streams_test.dart deleted file mode 100644 index 00888e9f..00000000 --- a/test/apis/twitch_gql_streams_test.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:frosty/apis/base_api_client.dart'; -import 'package:frosty/apis/twitch_gql_api.dart'; -import 'package:http_mock_adapter/http_mock_adapter.dart'; - -void main() { - late Dio dio; - late DioAdapter dioAdapter; - late TwitchGqlApi api; - - setUp(() { - dio = Dio(BaseOptions()); - dioAdapter = DioAdapter(dio: dio); - api = TwitchGqlApi(dio); - }); - - Map streamEdge({ - required String id, - required String login, - required int viewers, - String? cursor, - }) { - return { - 'cursor': cursor ?? 'cursor-$id', - 'node': { - 'id': id, - 'title': '$login live', - 'viewersCount': viewers, - 'createdAt': '2026-01-01T00:00:00Z', - 'language': 'en', - 'game': {'id': '1', 'name': 'Game'}, - 'broadcaster': { - 'id': id, - 'login': login, - 'displayName': login, - }, - }, - }; - } - - group('getStreams', () { - test('parses freeform tag page and cursor', () async { - dioAdapter.onPost( - 'https://gql.twitch.tv/gql', - (server) => server.reply(200, { - 'data': { - 'streams': { - 'edges': [ - streamEdge(id: '1', login: 'a', viewers: 10), - streamEdge(id: '2', login: 'b', viewers: 5), - ], - 'pageInfo': {'hasNextPage': true}, - }, - }, - }), - data: Matchers.any, - ); - - final page = await api.getStreams( - freeformTags: ['Deutsch'], - sort: StreamViewerSort.viewersAsc, - ); - - expect(page.data.map((s) => s.userLogin), ['a', 'b']); - expect(page.data.map((s) => s.viewerCount), [10, 5]); - expect(page.pagination['cursor'], 'cursor-2'); - }); - - test('throws IntegrityCheckException on integrity errors', () async { - dioAdapter.onPost( - 'https://gql.twitch.tv/gql', - (server) => server.reply(200, { - 'errors': [ - {'message': 'failed integrity check'}, - ], - 'data': {'streams': null}, - }), - data: Matchers.any, - ); - - await expectLater( - api.getStreams( - freeformTags: ['Deutsch'], - cursor: 'page-2', - authToken: 'web', - integrityToken: 'stale', - deviceId: 'device', - ), - throwsA(isA()), - ); - }); - - test('sends Client-Integrity and X-Device-Id when provided', () async { - dioAdapter.onPost( - 'https://gql.twitch.tv/gql', - (server) => server.reply(200, { - 'data': { - 'streams': { - 'edges': [streamEdge(id: '1', login: 'a', viewers: 1)], - 'pageInfo': {'hasNextPage': false}, - }, - }, - }), - data: Matchers.any, - headers: { - 'Client-ID': Matchers.any, - 'Content-Type': Matchers.any, - 'User-Agent': Matchers.any, - 'Authorization': 'OAuth web-token', - 'X-Device-Id': 'dev-123', - 'Client-Integrity': 'integrity-token', - }, - ); - - final page = await api.getStreams( - freeformTags: ['English'], - sort: StreamViewerSort.viewersDesc, - cursor: 'after-1', - authToken: 'web-token', - integrityToken: 'integrity-token', - deviceId: 'dev-123', - ); - - expect(page.data, hasLength(1)); - expect(page.pagination, isEmpty); - }); - }); -} diff --git a/test/apis/twitch_integrity_test.dart b/test/apis/twitch_integrity_test.dart deleted file mode 100644 index a07ec031..00000000 --- a/test/apis/twitch_integrity_test.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:frosty/apis/twitch_integrity.dart'; - -String _tokenFor(Map payload) { - final encoded = base64Url.encode(utf8.encode(jsonEncode(payload))); - return 'v4.public.$encoded.fakesig'; -} - -void main() { - group('TwitchIntegrity', () { - test('detects is_bad_bot string flag', () { - final token = _tokenFor({ - 'client_id': 'kimne78kx3ncx6brgo4mv6wki5h1ko', - 'device_id': 'abc123', - 'is_bad_bot': 'true', - 'exp': '2099-01-01T00:00:00Z', - }); - - expect(TwitchIntegrity.isBadBot(token), isTrue); - expect(TwitchIntegrity.deviceId(token), 'abc123'); - expect(TwitchIntegrity.expirationMs(token), isNotNull); - }); - - test('detects is_bad_bot bool flag', () { - final token = _tokenFor({ - 'is_bad_bot': true, - 'device_id': 'dev', - }); - expect(TwitchIntegrity.isBadBot(token), isTrue); - }); - - test('returns false when not flagged', () { - final token = _tokenFor({ - 'is_bad_bot': 'false', - 'device_id': 'dev', - }); - expect(TwitchIntegrity.isBadBot(token), isFalse); - }); - }); -} diff --git a/test/apis/unauthorized_interceptor_paths_test.dart b/test/apis/unauthorized_interceptor_paths_test.dart deleted file mode 100644 index a4855c54..00000000 --- a/test/apis/unauthorized_interceptor_paths_test.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:frosty/apis/unauthorized_interceptor.dart'; - -void main() { - group('moderation path helpers', () { - test('matches helix moderation paths', () { - expect( - isTwitchModeratedChannelsPath('/helix/moderation/channels'), - isTrue, - ); - expect(isTwitchModeratorActionPath('/helix/moderation/chat'), isTrue); - expect(isTwitchModeratorActionPath('/helix/moderation/bans'), isTrue); - expect( - isTwitchModeratorActionPath('/helix/moderation/channels'), - isFalse, - ); - expect(isTwitchModeratedChannelsPath('/helix/users'), isFalse); - }); - }); -} diff --git a/test/fixtures/irc_messages.dart b/test/fixtures/irc_messages.dart index a5bcbfaa..28624337 100644 --- a/test/fixtures/irc_messages.dart +++ b/test/fixtures/irc_messages.dart @@ -9,19 +9,6 @@ const basicPrivmsg = 'tmi-sent-ts=1234567890123;turbo=0;user-id=67890;user-type= ' ':testuser!testuser@testuser.tmi.twitch.tv PRIVMSG #channel :Hello World'; -/// Exercises the full IRCv3 tag escape set plus a value that itself contains -/// '='. Uses a raw string so the backslashes are literal wire bytes. -const messageWithEscapedTagChars = - r'@a-space=one\stwo;a-semi=left\:right;a-slash=a\\b;a-equals=k=v;' - r'a-double=x\\sy;display-name=EscapeAll;mod=0 ' - r':escapeall!escapeall@escapeall.tmi.twitch.tv PRIVMSG #channel :hi'; - -/// A message whose prefix is not the usual `nick!user@host` form (no '!'). -/// The parser should fall back to the login tag instead of throwing. -const messageNoBangPrefix = - '@login=fallbackuser;mod=0;id=nobang-1 ' - ':some.server.example PRIVMSG #channel :server style'; - /// A chat message with emotes in the tags /// Note: emote positions are 0-indexed byte positions in the message /// "Kappa LUL" - Kappa is at 0-4, LUL is at 6-8 @@ -151,16 +138,6 @@ const historicalMessage = 'tmi-sent-ts=1234567890123;turbo=0;user-id=12121;user-type= ' ':histuser!histuser@histuser.tmi.twitch.tv PRIVMSG #channel :Historical message'; -/// A historical message as serialized by the recent-messages API, which emits -/// empty tags as bare keys (e.g. `emotes`, `flags`) instead of the `key=` form -/// that live Twitch IRC uses. -const historicalMessageBareTags = - '@badges=raging-wolf-helm/1;emotes;first-msg=0;tmi-sent-ts=1234567890123;' - 'flags;mod=0;returning-chatter=0;rm-received-ts=1234567890456;color=#1E90FF;' - 'id=hist-bare-123;room-id=12345;historical=1;user-type;turbo=0;subscriber=0;' - 'badge-info;display-name=BareUser;user-id=18181 ' - ':bareuser!bareuser@bareuser.tmi.twitch.tv PRIVMSG #channel :Bare tag message'; - /// A message from a shared chat session (source room different from current) const sharedChatMessage = '@badge-info=;badges=;color=#00BFFF;display-name=SharedUser;emotes=;' diff --git a/test/models/irc_test.dart b/test/models/irc_test.dart index 2155d611..0d57776d 100644 --- a/test/models/irc_test.dart +++ b/test/models/irc_test.dart @@ -27,27 +27,6 @@ void main() { expect(msg.message, 'test message'); }); - test('unescapes the full IRCv3 tag escape set', () { - final msg = IRCMessage.fromString(messageWithEscapedTagChars); - - expect(msg.tags['a-space'], 'one two'); - expect(msg.tags['a-semi'], 'left;right'); - expect(msg.tags['a-slash'], r'a\b'); - // Only the first '=' splits key/value; later '=' stays in the value. - expect(msg.tags['a-equals'], 'k=v'); - // `\\` consumes both backslashes; the following 's' is a literal letter, - // not a `\s` space escape (single-pass unescaping). - expect(msg.tags['a-double'], r'x\sy'); - }); - - test('falls back to login tag when prefix has no "!"', () { - // An unexpected prefix shape must not throw and drop the message. - final msg = IRCMessage.fromString(messageNoBangPrefix); - - expect(msg.user, 'fallbackuser'); - expect(msg.message, 'server style'); - }); - test('handles message with no color tag', () { final msg = IRCMessage.fromString(messageNoColor); @@ -244,20 +223,6 @@ void main() { expect(msg.tags['historical'], '1'); }); - test('parses historical message with bare (valueless) tags', () { - // The recent-messages API serializes empty tags as bare keys (e.g. - // `emotes`, `flags`) instead of `key=`. This previously threw a - // RangeError, silently dropping all recent messages. - final msg = IRCMessage.fromString(historicalMessageBareTags); - - expect(msg.tags['historical'], '1'); - expect(msg.tags['display-name'], 'BareUser'); - expect(msg.message, 'Bare tag message'); - // Bare tags should be absent from the map, not crash parsing. - expect(msg.tags.containsKey('emotes'), isFalse); - expect(msg.tags.containsKey('flags'), isFalse); - }); - test('parses shared chat message with source room', () { final msg = IRCMessage.fromString(sharedChatMessage); @@ -451,8 +416,7 @@ void main() { expect(notice.command, Command.notice); expect(notice.message, 'Test notice'); expect(notice.raw, isEmpty); - expect(notice.tags, contains('tmi-sent-ts')); - expect(notice.tags.length, 1); + expect(notice.tags, isEmpty); }); test('creates notice with action callback', () { @@ -622,24 +586,6 @@ void main() { expect(msg.localEmotes, isNotNull); expect(msg.localEmotes!.containsKey('Kappa'), isTrue); }); - - test('returns Command.none for a truncated line without a command', () { - // A malformed/truncated line (e.g. from the recent-messages history API) - // whose message section is a single token used to throw a RangeError on - // splitMessage[1]. It must now parse safely as a none command. - final msg = IRCMessage.fromString('@historical=1 :justaprefix'); - - expect(msg.command, Command.none); - expect(msg.tags['historical'], '1'); - }); - - test('returns Command.none for a line with no separating space', () { - // No space means there are no tags to slice and no body to parse; must - // not throw on the substring/split calls. - final msg = IRCMessage.fromString('@onlytags'); - - expect(msg.command, Command.none); - }); }); group('USERSTATE', () { @@ -684,18 +630,5 @@ void main() { final updated = original.fromIRCMessage(ircMsg); expect(updated.mod, isFalse); }); - - test('missing mod and subscriber tags are false', () { - const original = USERSTATE(); - final ircMsg = IRCMessage( - raw: '', - command: Command.userState, - tags: {'display-name': 'TestUser'}, - ); - - final updated = original.fromIRCMessage(ircMsg); - expect(updated.mod, isFalse); - expect(updated.subscriber, isFalse); - }); }); } diff --git a/test/models/playback_access_token_test.dart b/test/models/playback_access_token_test.dart deleted file mode 100644 index fb09a3fa..00000000 --- a/test/models/playback_access_token_test.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:frosty/models/playback_access_token.dart'; - -Map gqlResponse({required String value, String sig = 'sig'}) => - { - 'data': { - 'streamPlaybackAccessToken': {'value': value, 'signature': sig}, - }, - }; - -void main() { - group('PlaybackAccessToken.fromGqlResponse', () { - test('parses hide_ads and restricted bitrates from token payload', () { - final value = jsonEncode({ - 'hide_ads': true, - 'chansub': { - 'restricted_bitrates': [ - '1080p60', - '720p60', - 'chunked', - 'archives', - 'sub_archives', - 'live', - 'audio_only', - ], - }, - }); - - final token = PlaybackAccessToken.fromGqlResponse( - gqlResponse(value: value), - ); - - expect(token.value, value); - expect(token.signature, 'sig'); - expect(token.hideAds, isTrue); - // chunked/archives/sub_archives/live are playlist-internal names, not - // real qualities; names merely containing those words must survive. - expect(token.restrictedQualities, [ - '1080p60', - '720p60', - 'audio_only', - ]); - }); - - test('does not filter qualities that merely contain reserved words', () { - final value = jsonEncode({ - 'chansub': { - 'restricted_bitrates': ['1080p60_live', 'olive', 'rearchives'], - }, - }); - - final token = PlaybackAccessToken.fromGqlResponse( - gqlResponse(value: value), - ); - - expect(token.restrictedQualities, ['1080p60_live', 'olive', 'rearchives']); - }); - - test('survives a non-JSON token payload', () { - final token = PlaybackAccessToken.fromGqlResponse( - gqlResponse(value: 'not-json'), - ); - - expect(token.value, 'not-json'); - expect(token.hideAds, isFalse); - expect(token.restrictedQualities, isEmpty); - }); - - test('throws FormatException when token is missing', () { - expect( - () => PlaybackAccessToken.fromGqlResponse({'data': null}), - throwsFormatException, - ); - }); - }); -} diff --git a/test/screens/channel/chat/banned_user_tracker_test.dart b/test/screens/channel/chat/banned_user_tracker_test.dart deleted file mode 100644 index 32093682..00000000 --- a/test/screens/channel/chat/banned_user_tracker_test.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:frosty/screens/channel/chat/stores/banned_user_tracker.dart'; - -void main() { - late BannedUserTracker tracker; - final now = DateTime(2026, 6, 28, 12); - setUp(() => tracker = BannedUserTracker()); - - test('an untracked user is not banned', () { - expect(tracker.isBannedOrTimedOut('1', now), isFalse); - }); - - test('a banned user stays banned (no expiry)', () { - tracker.recordBan('1'); - expect(tracker.isBannedOrTimedOut('1', now), isTrue); - expect( - tracker.isBannedOrTimedOut('1', now.add(const Duration(days: 365))), - isTrue, - ); - }); - - test('a timed-out user is banned until the timeout expires', () { - tracker.recordTimeout('1', const Duration(seconds: 60), now); - expect(tracker.isBannedOrTimedOut('1', now), isTrue); - expect( - tracker.isBannedOrTimedOut('1', now.add(const Duration(seconds: 59))), - isTrue, - ); - expect( - tracker.isBannedOrTimedOut('1', now.add(const Duration(seconds: 61))), - isFalse, - ); - }); - - test('clear lifts a ban', () { - tracker.recordBan('1'); - tracker.clear('1'); - expect(tracker.isBannedOrTimedOut('1', now), isFalse); - }); - - test('a later permanent ban overrides an earlier timeout', () { - tracker.recordTimeout('1', const Duration(seconds: 10), now); - tracker.recordBan('1'); - expect( - tracker.isBannedOrTimedOut('1', now.add(const Duration(seconds: 30))), - isTrue, - ); - }); -} diff --git a/test/screens/channel/chat/chat_interaction_pause_test.dart b/test/screens/channel/chat/chat_interaction_pause_test.dart deleted file mode 100644 index f535c5a4..00000000 --- a/test/screens/channel/chat/chat_interaction_pause_test.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:frosty/screens/channel/chat/stores/chat_interaction_pause.dart'; - -void main() { - late ChatInteractionPause pause; - - setUp(() => pause = ChatInteractionPause()); - - // A move clearly past the touch slop (a scroll), and one well within it. - final beyondSlop = Offset(0, kTouchSlop + 10); - final withinSlop = Offset(0, kTouchSlop - 5); - - test('is not paused initially', () { - expect(pause.isPaused, isFalse); - }); - - test('is paused while a stationary pointer is down (long-press candidate)', () { - pause.pointerDown(1, Offset.zero); - - expect(pause.isPaused, isTrue); - }); - - test('resumes once the pointer is lifted', () { - pause.pointerDown(1, Offset.zero); - pause.pointerUp(1); - - expect(pause.isPaused, isFalse); - }); - - test('resumes when the pointer moves beyond the touch slop (scrolling)', () { - pause.pointerDown(1, Offset.zero); - pause.pointerMove(1, beyondSlop); - - // The finger is dragging — this is a scroll, not a hold, so the chat must - // keep flowing rather than freeze. - expect(pause.isPaused, isFalse); - }); - - test('stays paused for tiny jitter within the touch slop', () { - pause.pointerDown(1, Offset.zero); - pause.pointerMove(1, withinSlop); - - expect(pause.isPaused, isTrue); - }); - - test('stays resumed for the rest of the gesture even if the finger drifts ' - 'back within slop', () { - pause.pointerDown(1, Offset.zero); - pause.pointerMove(1, beyondSlop); - pause.pointerMove(1, withinSlop); - - // Once a gesture is recognized as a scroll it should not flip back to a - // hold mid-drag. - expect(pause.isPaused, isFalse); - }); - - test('stays paused until every stationary pointer lifts (multi-touch)', () { - pause.pointerDown(1, Offset.zero); - pause.pointerDown(2, Offset.zero); - pause.pointerUp(1); - - expect(pause.isPaused, isTrue); - - pause.pointerUp(2); - expect(pause.isPaused, isFalse); - }); - - test('a stray pointer-up never leaves the gate stuck', () { - pause.pointerUp(99); - - expect(pause.isPaused, isFalse); - - pause.pointerDown(1, Offset.zero); - expect(pause.isPaused, isTrue); - }); - - test('reset clears all active pointers', () { - pause.pointerDown(1, Offset.zero); - pause.pointerDown(2, Offset.zero); - - pause.reset(); - - expect(pause.isPaused, isFalse); - }); -} diff --git a/test/screens/channel/chat/shared_chat_bubble_state_test.dart b/test/screens/channel/chat/shared_chat_bubble_state_test.dart deleted file mode 100644 index 65a2b927..00000000 --- a/test/screens/channel/chat/shared_chat_bubble_state_test.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:frosty/screens/channel/chat/stores/shared_chat_bubble_state.dart'; - -void main() { - late SharedChatBubbleState state; - setUp(() => state = SharedChatBubbleState()); - - test('a channel is neither focused nor hidden by default', () { - expect(state.isFocused('1'), isFalse); - expect(state.isHidden('1'), isFalse); - }); - - test('toggleFocus spotlights an unfocused channel', () { - state.toggleFocus('1'); - expect(state.isFocused('1'), isTrue); - }); - - test('toggleFocus un-spotlights an already-focused channel', () { - state.toggleFocus('1'); - state.toggleFocus('1'); - expect(state.isFocused('1'), isFalse); - }); - - test('multiple channels can be spotlighted at once', () { - state.toggleFocus('1'); - state.toggleFocus('2'); - expect(state.isFocused('1'), isTrue); - expect(state.isFocused('2'), isTrue); - }); - - test('hide filters a channel and drops it from the spotlight', () { - state.toggleFocus('1'); - state.hide('1'); - expect(state.isHidden('1'), isTrue); - expect(state.isFocused('1'), isFalse); - }); - - test('toggleFocus is a no-op for a hidden channel', () { - state.hide('1'); - state.toggleFocus('1'); - expect(state.isFocused('1'), isFalse); - }); - - test('show lifts a hide', () { - state.hide('1'); - state.show('1'); - expect(state.isHidden('1'), isFalse); - }); - - test('reset clears both focused and hidden channels', () { - state.toggleFocus('1'); - state.hide('2'); - state.reset(); - expect(state.isFocused('1'), isFalse); - expect(state.isHidden('2'), isFalse); - }); -} diff --git a/test/screens/channel/video/chat_latency_sync_test.dart b/test/screens/channel/video/chat_latency_sync_test.dart deleted file mode 100644 index 6d9a49fb..00000000 --- a/test/screens/channel/video/chat_latency_sync_test.dart +++ /dev/null @@ -1,111 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:frosty/screens/channel/video/chat_latency_sync.dart'; -import 'package:frosty/screens/channel/video/video_timing_constants.dart'; -import 'package:frosty/screens/settings/stores/settings_store.dart'; - -void main() { - // Reference the real constant so assertions track config changes rather - // than a hardcoded magic number. - final tolerance = - VideoTimingConstants.chatSyncDriftToleranceSeconds.toDouble(); - - late SettingsStore settingsStore; - late ChatLatencySync sync; - - setUp(() { - settingsStore = SettingsStore.fromJson({}); - sync = ChatLatencySync(settingsStore: settingsStore); - }); - - group('ChatLatencySync reset', () { - test('zeroes syncedChatDelay when autoSyncChatDelay is enabled', () { - settingsStore.autoSyncChatDelay = true; - settingsStore.syncedChatDelay = 7.5; - - sync.reset(); - - expect(settingsStore.syncedChatDelay, 0.0); - }); - - test('is a no-op when autoSyncChatDelay is disabled', () { - settingsStore.autoSyncChatDelay = false; - settingsStore.syncedChatDelay = 7.5; - - sync.reset(); - - expect(settingsStore.syncedChatDelay, 7.5); - }); - }); - - group('ChatLatencySync report', () { - test('does nothing when autoSyncChatDelay is disabled', () { - settingsStore.autoSyncChatDelay = false; - settingsStore.syncedChatDelay = 0.0; - - sync.report(12.0); - - expect(settingsStore.syncedChatDelay, 0.0); - }); - - test('writes the first measurement when current delay is zero', () { - settingsStore.autoSyncChatDelay = true; - settingsStore.syncedChatDelay = 0.0; - - // Even a tiny value writes because the current value is exactly 0. - sync.report(0.5); - - expect(settingsStore.syncedChatDelay, 0.5); - }); - - test('writes when drift exceeds the tolerance', () { - settingsStore.autoSyncChatDelay = true; - settingsStore.syncedChatDelay = 5.0; - - final report = 5.0 + tolerance + 1.0; - sync.report(report); - - expect(settingsStore.syncedChatDelay, report); - }); - - test('does not write when drift is within the tolerance', () { - settingsStore.autoSyncChatDelay = true; - settingsStore.syncedChatDelay = 5.0; - - // Strictly inside the tolerance band. - sync.report(5.0 + (tolerance - 0.5)); - - expect(settingsStore.syncedChatDelay, 5.0); - }); - - test('does not write when drift is exactly at the tolerance', () { - settingsStore.autoSyncChatDelay = true; - settingsStore.syncedChatDelay = 5.0; - - // The code uses strictly-greater-than (`>`), so a drift equal to the - // tolerance must NOT write. - sync.report(5.0 + tolerance); - - expect(settingsStore.syncedChatDelay, 5.0); - }); - - test('writes when drift just exceeds the tolerance', () { - settingsStore.autoSyncChatDelay = true; - settingsStore.syncedChatDelay = 5.0; - - final report = 5.0 + tolerance + 0.1; - sync.report(report); - - expect(settingsStore.syncedChatDelay, report); - }); - - test('writes when a downward drift exceeds the tolerance', () { - settingsStore.autoSyncChatDelay = true; - settingsStore.syncedChatDelay = 10.0; - - final report = 10.0 - tolerance - 1.0; - sync.report(report); - - expect(settingsStore.syncedChatDelay, report); - }); - }); -} diff --git a/test/screens/channel/video/playback_error_recovery_test.dart b/test/screens/channel/video/playback_error_recovery_test.dart deleted file mode 100644 index adc80903..00000000 --- a/test/screens/channel/video/playback_error_recovery_test.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:frosty/screens/channel/video/playback_error_recovery.dart'; -import 'package:frosty/screens/channel/video/video_timing_constants.dart'; - -void main() { - group('shouldAutoRecoverFromPlayerError', () { - test('recovers Source error / generic failures under the refresh cap', () { - expect( - shouldAutoRecoverFromPlayerError(refreshCapReached: false), - isTrue, - ); - expect( - shouldAutoRecoverFromPlayerError( - refreshCapReached: false, - errorCode: null, - ), - isTrue, - ); - }); - - test('stops once the refresh cap is reached', () { - expect( - shouldAutoRecoverFromPlayerError(refreshCapReached: true), - isFalse, - ); - }); - - test('defers mediaServicesWereReset to its dedicated rebuild path', () { - expect( - shouldAutoRecoverFromPlayerError( - refreshCapReached: false, - errorCode: 'mediaServicesWereReset', - ), - isFalse, - ); - }); - }); - - group('playerErrorRetryDelay', () { - test('refreshes 4xx token failures immediately', () { - expect(playerErrorRetryDelay(statusCode: 403), Duration.zero); - expect(playerErrorRetryDelay(statusCode: 404), Duration.zero); - }); - - test('waits briefly for network / Source errors without status', () { - expect( - playerErrorRetryDelay(), - VideoTimingConstants.initRetryDelay, - ); - expect( - playerErrorRetryDelay(statusCode: 500), - VideoTimingConstants.initRetryDelay, - ); - }); - }); -} diff --git a/test/screens/channel/video/stream_info_poller_test.dart b/test/screens/channel/video/stream_info_poller_test.dart deleted file mode 100644 index 935a98ad..00000000 --- a/test/screens/channel/video/stream_info_poller_test.dart +++ /dev/null @@ -1,194 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_test/flutter_test.dart'; -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/stream_info_poller.dart'; -import 'package:mocktail/mocktail.dart'; - -import '../../../fixtures/api_responses.dart'; - -class MockTwitchApi extends Mock implements TwitchApi {} - -void main() { - const userLogin = 'testuser'; - const userId = '12345'; - - late MockTwitchApi api; - - // Fresh fixtures per test so nothing leaks between cases. - StreamTwitch buildStream() => StreamTwitch.fromJson( - (twitchStreamResponse['data']! as List).first as Map, - ); - - Channel buildChannel() => Channel.fromJson( - (twitchChannelResponse['data']! as List).first as Map, - ); - - // Fresh poller per test so _lastUpdate / _inFlight debounce state never - // carries over between tests. - StreamInfoPoller buildPoller() => - StreamInfoPoller(twitchApi: api, userLogin: userLogin, userId: userId); - - setUp(() { - api = MockTwitchApi(); - }); - - group('live', () { - test('returns stream when getStream succeeds', () async { - when( - () => api.getStream(userLogin: any(named: 'userLogin')), - ).thenAnswer((_) async => buildStream()); - - final poller = buildPoller(); - final result = await poller.fetch(); - - expect(result, isNotNull); - expect(result!.stream, isNotNull); - expect(result.stream!.userName, 'TestUser'); - expect(result.offlineChannel, isNull); - - verify(() => api.getStream(userLogin: userLogin)).called(1); - verifyNever(() => api.getChannel(userId: any(named: 'userId'))); - }); - }); - - group('debounce', () { - test( - 'second fetch within debounce window returns null, forceUpdate bypasses', - () async { - when( - () => api.getStream(userLogin: any(named: 'userLogin')), - ).thenAnswer((_) async => buildStream()); - - final poller = buildPoller(); - - // First fetch resolves with a real result. - final first = await poller.fetch(); - expect(first, isNotNull); - expect(first!.stream, isNotNull); - - // Immediate second fetch (no forceUpdate) is inside the 5s debounce - // window -> debounced -> null. - final second = await poller.fetch(); - expect(second, isNull); - - // forceUpdate bypasses the debounce -> a fresh result. - final forced = await poller.fetch(forceUpdate: true); - expect(forced, isNotNull); - expect(forced!.stream, isNotNull); - - // Two real fetches (first + forced); the debounced one issued no call. - verify(() => api.getStream(userLogin: userLogin)).called(2); - }, - ); - }); - - group('in-flight dedup', () { - test( - 'concurrent fetches await the same request and call getStream once', - () async { - final completer = Completer(); - when( - () => api.getStream(userLogin: any(named: 'userLogin')), - ).thenAnswer((_) => completer.future); - - final poller = buildPoller(); - - // Fire both without awaiting so the second sees the in-flight future. - final firstFuture = poller.fetch(); - final secondFuture = poller.fetch(); - - // Now let the underlying request complete. - completer.complete(buildStream()); - - final first = await firstFuture; - final second = await secondFuture; - - expect(first, isNotNull); - expect(second, isNotNull); - // Same deduplicated result instance. - expect(identical(first, second), isTrue); - expect(first!.stream, isNotNull); - - verify(() => api.getStream(userLogin: userLogin)).called(1); - }, - ); - }); - - group('second-chance retry', () { - test( - 'getStream fails once then succeeds on retry -> stream, no getChannel', - () async { - var calls = 0; - when(() => api.getStream(userLogin: any(named: 'userLogin'))).thenAnswer( - (_) async { - calls++; - if (calls == 1) throw Exception('transient drop'); - return buildStream(); - }, - ); - - final poller = buildPoller(); - final result = await poller.fetch(); - - expect(result, isNotNull); - expect(result!.stream, isNotNull); - expect(result.offlineChannel, isNull); - - verify(() => api.getStream(userLogin: userLogin)).called(2); - verifyNever(() => api.getChannel(userId: any(named: 'userId'))); - }, - ); - }); - - group('offline', () { - test( - 'getStream fails twice, getChannel succeeds -> offlineChannel', - () async { - when( - () => api.getStream(userLogin: any(named: 'userLogin')), - ).thenThrow(Exception('offline')); - when( - () => api.getChannel(userId: any(named: 'userId')), - ).thenAnswer((_) async => buildChannel()); - - final poller = buildPoller(); - final result = await poller.fetch(); - - expect(result, isNotNull); - expect(result!.stream, isNull); - expect(result.offlineChannel, isNotNull); - expect(result.offlineChannel!.broadcasterName, 'TestUser'); - - verify(() => api.getStream(userLogin: userLogin)).called(2); - verify(() => api.getChannel(userId: userId)).called(1); - }, - ); - }); - - group('total failure', () { - test( - 'getStream fails twice and getChannel fails -> empty result', - () async { - when( - () => api.getStream(userLogin: any(named: 'userLogin')), - ).thenThrow(Exception('stream boom')); - when( - () => api.getChannel(userId: any(named: 'userId')), - ).thenThrow(Exception('channel boom')); - - final poller = buildPoller(); - final result = await poller.fetch(); - - expect(result, isNotNull); - expect(result!.stream, isNull); - expect(result.offlineChannel, isNull); - - verify(() => api.getStream(userLogin: userLogin)).called(2); - verify(() => api.getChannel(userId: userId)).called(1); - }, - ); - }); -} diff --git a/test/screens/channel/video/video_timing_constants_test.dart b/test/screens/channel/video/video_timing_constants_test.dart deleted file mode 100644 index 9632ee94..00000000 --- a/test/screens/channel/video/video_timing_constants_test.dart +++ /dev/null @@ -1,159 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:frosty/screens/channel/video/video_timing_constants.dart'; - -/// Regression guard for the cross-layer ordering invariants documented in -/// [VideoTimingConstants]. These constants are tuned against native code in -/// two other layers (iOS notification-stall recovery and the Android -/// VideoPlayerObserver watchdog) that are NOT visible from Dart. If someone -/// retunes a value past a safe bound, these tests fail with a message quoting -/// the contract so the regression is self-explanatory. -void main() { - group('VideoTimingConstants stall-timer ordering', () { - test( - 'stallDetectionDelay sits between iOS ~1s recovery and Android 12s watchdog', - () { - final seconds = VideoTimingConstants.stallDetectionDelay.inSeconds; - - expect( - seconds, - greaterThan(1), - reason: - 'Contract: 1s < stallDetectionDelay. iOS native notification-stall ' - 'recovery fires at ~1s; the Dart timer must not race ahead of it.', - ); - expect( - seconds, - lessThan(12), - reason: - 'Contract: stallDetectionDelay < 12s. The Android native watchdog ' - '(VideoPlayerObserver.kt STALL_WATCHDOG_MS) fires at 12s with a ' - 'heavier stop()/prepare() recovery. Dart\'s attempt-1 seek must ' - 'pre-empt it, so it must fire first.', - ); - }, - ); - }); - - group('VideoTimingConstants refreshBackoff', () { - test('is non-empty and starts at Duration.zero', () { - expect(VideoTimingConstants.refreshBackoff, isNotEmpty); - expect( - VideoTimingConstants.refreshBackoff.first, - Duration.zero, - reason: - 'First refresh attempt should be immediate (0 = first attempt per ' - 'the doc comment).', - ); - }); - - test('covers every refresh attempt up to maxRefreshAttempts', () { - // The comment states attempts beyond the list length fall through to the - // error path via [maxRefreshAttempts]. So the list must be long enough to - // back off every attempt before that fall-through, i.e. cover all - // maxRefreshAttempts attempts. - expect( - VideoTimingConstants.refreshBackoff.length, - greaterThanOrEqualTo(VideoTimingConstants.maxRefreshAttempts), - reason: - 'refreshBackoff must index every attempt up to maxRefreshAttempts ' - '(${VideoTimingConstants.maxRefreshAttempts}); a shorter list would ' - 'drop a backoff and the attempt would fall through to the error ' - 'path prematurely.', - ); - }); - - test('delays are monotonically non-decreasing', () { - final backoff = VideoTimingConstants.refreshBackoff; - for (var i = 1; i < backoff.length; i++) { - expect( - backoff[i], - greaterThanOrEqualTo(backoff[i - 1]), - reason: - 'Backoff must not shrink between attempts: index $i ' - '(${backoff[i]}) < index ${i - 1} (${backoff[i - 1]}). ' - 'Later retries should wait at least as long as earlier ones.', - ); - } - }); - }); - - group('VideoTimingConstants latency vs chat-sync tolerance', () { - test('highLatencyThreshold is much greater than chatSyncDriftTolerance', () { - expect( - VideoTimingConstants.highLatencyThresholdSeconds, - greaterThan(VideoTimingConstants.chatSyncDriftToleranceSeconds), - reason: - 'High-latency intervention (seek/refresh) must only trigger far ' - 'past the chat-sync drift tolerance ' - '(${VideoTimingConstants.chatSyncDriftToleranceSeconds}s); otherwise ' - 'normal sync drift would be misread as a latency problem and trigger ' - 'recovery.', - ); - }); - - test('highLatencyThreshold sits comfortably above normal live latency', () { - // Normal live latency is well under ~15s; the threshold must clear that - // so routine live playback is never flagged as high-latency. - expect( - VideoTimingConstants.highLatencyThresholdSeconds, - greaterThanOrEqualTo(15), - reason: - 'highLatencyThresholdSeconds must stay above normal live latency ' - '(under ~15s) so healthy playback is not flagged for recovery.', - ); - }); - }); - - group('VideoTimingConstants short-play loop detection', () { - test('shortPlayWindow is positive', () { - expect(VideoTimingConstants.shortPlayWindow, greaterThan(Duration.zero)); - }); - - test('shortPlayLoopThreshold is a small positive int', () { - expect( - VideoTimingConstants.shortPlayLoopThreshold, - greaterThan(0), - reason: 'Need at least one bounce to detect a failed-recovery loop.', - ); - expect( - VideoTimingConstants.shortPlayLoopThreshold, - lessThanOrEqualTo(10), - reason: - 'Threshold should stay small so a tailored error surfaces promptly ' - 'rather than after many silent loops.', - ); - }); - }); - - group('VideoTimingConstants overlay ordering', () { - test('overlayQuickHide is not slower than overlayAutoHide', () { - expect( - VideoTimingConstants.overlayQuickHide, - lessThanOrEqualTo(VideoTimingConstants.overlayAutoHide), - reason: - 'The quick-hide path must hide the overlay at least as fast as the ' - 'auto-hide path; otherwise "quick" would be slower than the default.', - ); - }); - }); - - group('VideoTimingConstants positive intervals', () { - test('nativeStreamInfoInterval is positive', () { - expect( - VideoTimingConstants.nativeStreamInfoInterval, - greaterThan(Duration.zero), - ); - }); - - test('latencyPollingInterval is positive', () { - expect( - VideoTimingConstants.latencyPollingInterval, - greaterThan(Duration.zero), - ); - }); - - test('maxRefreshAttempts is positive', () { - expect(VideoTimingConstants.maxRefreshAttempts, greaterThan(0)); - }); - }); -} diff --git a/test/screens/home/stream_list/stream_list_store_test.dart b/test/screens/home/stream_list/stream_list_store_test.dart deleted file mode 100644 index 1a066c66..00000000 --- a/test/screens/home/stream_list/stream_list_store_test.dart +++ /dev/null @@ -1,203 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:frosty/apis/base_api_client.dart'; -import 'package:frosty/apis/twitch_api.dart'; -import 'package:frosty/apis/twitch_gql_api.dart'; -import 'package:frosty/models/stream.dart'; -import 'package:frosty/models/user.dart'; -import 'package:frosty/screens/home/stream_list/stream_list_store.dart'; -import 'package:frosty/screens/settings/stores/auth_store.dart'; -import 'package:frosty/screens/settings/stores/settings_store.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -class MockTwitchApi extends Mock implements TwitchApi {} - -class MockTwitchGqlApi extends Mock implements TwitchGqlApi {} - -void main() { - setUpAll(() { - registerFallbackValue(StreamViewerSort.viewersDesc); - registerFallbackValue([]); - }); - - late MockTwitchApi api; - late MockTwitchGqlApi gqlApi; - late AuthStore authStore; - late SettingsStore settingsStore; - - StreamTwitch stream(String id, int viewers) => StreamTwitch( - id, - 'user$id', - 'User$id', - 'game', - 'Game', - 'title', - viewers, - '2026-01-01T00:00:00Z', - 'https://cdn/live_user_$id-{width}x{height}.jpg', - ); - - Future waitUntilIdle(ListStore store) async { - await Future.delayed(Duration.zero); - for (var i = 0; i < 50 && store.isLoading; i++) { - await Future.delayed(const Duration(milliseconds: 5)); - } - } - - setUp(() { - SharedPreferences.setMockInitialValues({}); - api = MockTwitchApi(); - gqlApi = MockTwitchGqlApi(); - authStore = AuthStore(twitchApi: api, twitchGqlApi: gqlApi); - settingsStore = SettingsStore()..streamLanguages = []; - - when(() => api.getUserInfo()).thenAnswer( - (_) async => const UserTwitch( - '123', - 'me', - 'Me', - 'https://cdn/me.png', - ), - ); - when(() => api.getUserBlockedList(id: any(named: 'id'))).thenAnswer( - (_) async => [], - ); - }); - - group('ListStore tag GQL pagination', () { - test('keeps server ASC order across GQL pages', () async { - when( - () => gqlApi.getStreams( - freeformTags: any(named: 'freeformTags'), - languages: any(named: 'languages'), - sort: any(named: 'sort'), - cursor: any(named: 'cursor'), - first: any(named: 'first'), - authToken: any(named: 'authToken'), - integrityToken: any(named: 'integrityToken'), - deviceId: any(named: 'deviceId'), - ), - ).thenAnswer((invocation) async { - final cursor = invocation.namedArguments[#cursor] as String?; - if (cursor == null) { - return StreamsTwitch( - [stream('1', 3), stream('2', 7)], - const {'cursor': 'c1'}, - ); - } - expect(cursor, 'c1'); - return StreamsTwitch( - [stream('3', 12), stream('4', 20)], - const {}, - ); - }); - - settingsStore.streamViewerSort = StreamViewerSort.viewersAsc; - - final store = ListStore( - authStore: authStore, - settingsStore: settingsStore, - twitchApi: api, - twitchGqlApi: gqlApi, - listType: ListType.tag, - freeformTag: 'Deutsch', - ); - - await waitUntilIdle(store); - - expect(store.streams.map((s) => s.userId), ['1', '2']); - expect(store.hasMore, isTrue); - - await store.getStreams(); - await waitUntilIdle(store); - - expect(store.streams.map((s) => s.userId), ['1', '2', '3', '4']); - expect(store.streams.map((s) => s.viewerCount), [3, 7, 12, 20]); - expect(store.hasMore, isFalse); - verifyNever( - () => api.getDirectoryStreams( - languages: any(named: 'languages'), - gameId: any(named: 'gameId'), - freeformTag: any(named: 'freeformTag'), - cursor: any(named: 'cursor'), - minCount: any(named: 'minCount'), - maxPages: any(named: 'maxPages'), - ), - ); - - store.dispose(); - }); - - test('falls back to Helix when GQL page-2 integrity fails', () async { - var gqlCalls = 0; - when( - () => gqlApi.getStreams( - freeformTags: any(named: 'freeformTags'), - languages: any(named: 'languages'), - sort: any(named: 'sort'), - cursor: any(named: 'cursor'), - first: any(named: 'first'), - authToken: any(named: 'authToken'), - integrityToken: any(named: 'integrityToken'), - deviceId: any(named: 'deviceId'), - ), - ).thenAnswer((invocation) async { - gqlCalls++; - final cursor = invocation.namedArguments[#cursor] as String?; - if (cursor == null) { - return StreamsTwitch( - [stream('1', 100)], - const {'cursor': 'c1'}, - ); - } - throw const IntegrityCheckException('failed integrity check'); - }); - - when( - () => api.getDirectoryStreams( - languages: any(named: 'languages'), - gameId: any(named: 'gameId'), - freeformTag: any(named: 'freeformTag'), - cursor: any(named: 'cursor'), - minCount: any(named: 'minCount'), - maxPages: any(named: 'maxPages'), - ), - ).thenAnswer( - (_) async => StreamsTwitch( - [stream('1', 100), stream('9', 50)], - const {}, - ), - ); - - final store = ListStore( - authStore: authStore, - settingsStore: settingsStore, - twitchApi: api, - twitchGqlApi: gqlApi, - listType: ListType.tag, - freeformTag: 'Deutsch', - ); - - await waitUntilIdle(store); - expect(store.streams.map((s) => s.userId), ['1']); - - await store.getStreams(); - await waitUntilIdle(store); - - expect(gqlCalls, greaterThanOrEqualTo(2)); - expect(store.streams.map((s) => s.userId), ['1', '9']); - verify( - () => api.getDirectoryStreams( - languages: any(named: 'languages'), - gameId: any(named: 'gameId'), - freeformTag: any(named: 'freeformTag'), - cursor: any(named: 'cursor'), - minCount: any(named: 'minCount'), - maxPages: any(named: 'maxPages'), - ), - ).called(1); - - store.dispose(); - }); - }); -} diff --git a/test/screens/settings/stores/settings_store_test.dart b/test/screens/settings/stores/settings_store_test.dart index f6e6612c..8dc7dcc8 100644 --- a/test/screens/settings/stores/settings_store_test.dart +++ b/test/screens/settings/stores/settings_store_test.dart @@ -14,8 +14,6 @@ void main() { expect(store.showVideo, isTrue); expect(store.defaultToHighestQuality, isFalse); expect(store.useTextureRendering, isTrue); - expect(store.useNativePlayer, isTrue); - expect(store.blockTwitchAds, isTrue); expect(store.showOverlay, isTrue); expect(store.toggleableOverlay, isFalse); expect(store.showLatency, isFalse); @@ -29,8 +27,6 @@ void main() { expect(store.timestampType, TimestampType.disabled); expect(store.autoSyncChatDelay, isFalse); expect(store.chatDelay, 0.0); - expect(store.syncedChatDelay, 0.0); - expect(store.effectiveChatDelay, 0.0); expect(store.highlightFirstTimeChatter, isTrue); expect(store.showUserNotices, isTrue); expect(store.emoteMenuButtonOnLeft, isFalse); @@ -67,7 +63,6 @@ void main() { store.showThumbnails = false; store.fontSize = 16.0; store.chatDelay = 5.0; - store.syncedChatDelay = 8.0; store.showVideo = false; store.timestampType = TimestampType.twelve; store.landscapeCutout = LandscapeCutoutType.both; @@ -82,8 +77,6 @@ void main() { expect(restored.showThumbnails, isFalse); expect(restored.fontSize, 16.0); expect(restored.chatDelay, 5.0); - expect(restored.syncedChatDelay, 0.0); - expect(json.containsKey('syncedChatDelay'), isFalse); expect(restored.showVideo, isFalse); expect(restored.timestampType, TimestampType.twelve); expect(restored.landscapeCutout, LandscapeCutoutType.both); @@ -92,7 +85,9 @@ void main() { }); test('unknown ThemeType enum value falls back to system', () { - final store = SettingsStore.fromJson({'themeType': 'nonexistent_theme'}); + final store = SettingsStore.fromJson({ + 'themeType': 'nonexistent_theme', + }); expect(store.themeType, ThemeType.system); }); @@ -104,34 +99,6 @@ void main() { }); }); - group('native player default migration', () { - test('5.1.0 WebView default is flipped on once', () { - final store = SettingsStore.fromJson({'useNativePlayer': false}); - - expect(store.useNativePlayer, isTrue); - expect(store.nativePlayerV52Migrated, isTrue); - }); - - test('opt-out is kept after the one-shot migration', () { - final store = SettingsStore.fromJson({ - 'useNativePlayer': false, - 'nativePlayerV52Migrated': true, - }); - - expect(store.useNativePlayer, isFalse); - }); - - test('roundtrip after opt-out stays on WebView', () { - final store = SettingsStore.fromJson({}); - store.useNativePlayer = false; - - final restored = SettingsStore.fromJson(store.toJson()); - - expect(restored.useNativePlayer, isFalse); - expect(restored.nativePlayerV52Migrated, isTrue); - }); - }); - group('SettingsStore reset actions', () { test('resetGeneralSettings restores general defaults', () { final store = SettingsStore.fromJson({}); @@ -157,7 +124,6 @@ void main() { store.showVideo = false; store.defaultToHighestQuality = true; store.useTextureRendering = false; - store.useNativePlayer = false; store.showOverlay = false; store.toggleableOverlay = true; store.showLatency = true; @@ -167,7 +133,6 @@ void main() { expect(store.showVideo, isTrue); expect(store.defaultToHighestQuality, isFalse); expect(store.useTextureRendering, isTrue); - expect(store.useNativePlayer, isTrue); expect(store.showOverlay, isTrue); expect(store.toggleableOverlay, isFalse); expect(store.showLatency, isFalse); @@ -182,7 +147,6 @@ void main() { store.showDeletedMessages = true; store.timestampType = TimestampType.twentyFour; store.chatDelay = 10.0; - store.syncedChatDelay = 7.0; store.emoteMenuButtonOnLeft = true; store.landscapeCutout = LandscapeCutoutType.right; store.chatWidth = 0.5; @@ -198,7 +162,6 @@ void main() { expect(store.showDeletedMessages, isFalse); expect(store.timestampType, TimestampType.disabled); expect(store.chatDelay, 0.0); - expect(store.syncedChatDelay, 0.0); expect(store.emoteMenuButtonOnLeft, isFalse); expect(store.landscapeCutout, LandscapeCutoutType.none); expect(store.chatWidth, 0.2); @@ -273,19 +236,6 @@ void main() { expect(store.showLatency, isFalse); expect(store.fontSize, 20.0); // Should remain changed }); - - test('effectiveChatDelay uses synced value only during auto sync', () { - final store = SettingsStore.fromJson({}); - - store.chatDelay = 4.0; - store.syncedChatDelay = 9.0; - - expect(store.effectiveChatDelay, 4.0); - - store.autoSyncChatDelay = true; - - expect(store.effectiveChatDelay, 9.0); - }); }); group('Enums', () { diff --git a/test/screens/settings/stores/user_store_test.dart b/test/screens/settings/stores/user_store_test.dart deleted file mode 100644 index 4897d3ad..00000000 --- a/test/screens/settings/stores/user_store_test.dart +++ /dev/null @@ -1,268 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:frosty/apis/base_api_client.dart'; -import 'package:frosty/apis/twitch_api.dart'; -import 'package:frosty/apis/twitch_gql_api.dart'; -import 'package:frosty/apis/twitch_integrity.dart'; -import 'package:frosty/models/user.dart'; -import 'package:frosty/screens/settings/stores/user_store.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -class MockTwitchApi extends Mock implements TwitchApi {} - -class MockTwitchGqlApi extends Mock implements TwitchGqlApi {} - -void main() { - late MockTwitchApi api; - late MockTwitchGqlApi gqlApi; - - setUp(() { - SharedPreferences.setMockInitialValues({}); - api = MockTwitchApi(); - gqlApi = MockTwitchGqlApi(); - when(() => api.getUserInfo()).thenAnswer( - (_) async => const UserTwitch( - '12345', - 'testuser', - 'TestUser', - 'https://cdn/profile.png', - ), - ); - when(() => api.getUserBlockedList(id: any(named: 'id'))).thenAnswer( - (_) async => [], - ); - when(() => api.getModeratedChannels(id: any(named: 'id'))).thenAnswer( - (_) async => ['999'], - ); - }); - - UserStore makeStore({required bool hasModeratorScopes}) => UserStore( - twitchApi: api, - twitchGqlApi: gqlApi, - hasModeratorScopes: () => hasModeratorScopes, - ); - - group('UserStore moderated channels gating', () { - test('init skips moderated-channels without moderator scopes', () async { - final userStore = makeStore(hasModeratorScopes: false); - - await userStore.init(); - await Future.delayed(Duration.zero); - - verifyNever(() => api.getModeratedChannels(id: any(named: 'id'))); - expect(userStore.moderatedChannels, isEmpty); - }); - - test('init fetches moderated-channels when moderator scopes exist', () async { - final userStore = makeStore(hasModeratorScopes: true); - - await userStore.init(); - await Future.delayed(Duration.zero); - - verify(() => api.getModeratedChannels(id: '12345')).called(1); - expect(userStore.moderatedChannels, ['999']); - }); - - test('refreshModeratedChannels no-ops without moderator scopes', () async { - final userStore = makeStore(hasModeratorScopes: false); - await userStore.init(); - clearInteractions(api); - - await userStore.refreshModeratedChannels(); - verifyNever(() => api.getModeratedChannels(id: any(named: 'id'))); - }); - - test('deleteMessage requires moderator scopes', () async { - when( - () => api.deleteChatMessage( - broadcasterId: any(named: 'broadcasterId'), - moderatorId: any(named: 'moderatorId'), - messageId: any(named: 'messageId'), - ), - ).thenAnswer((_) async => true); - - final userStore = makeStore(hasModeratorScopes: false); - await userStore.init(); - - final result = await userStore.deleteMessage( - broadcasterId: '12345', - messageId: 'msg', - ); - - expect(result, isFalse); - verifyNever( - () => api.deleteChatMessage( - broadcasterId: any(named: 'broadcasterId'), - moderatorId: any(named: 'moderatorId'), - messageId: any(named: 'messageId'), - ), - ); - }); - }); - - group('UserStore follow', () { - test('isFollowingChannel caches Helix result', () async { - when( - () => api.isFollowingChannel( - userId: '12345', - broadcasterId: '999', - ), - ).thenAnswer((_) async => true); - - final userStore = makeStore(hasModeratorScopes: false); - await userStore.init(); - - expect(await userStore.isFollowingChannel('999'), isTrue); - expect(await userStore.isFollowingChannel('999'), isTrue); - verify( - () => api.isFollowingChannel( - userId: '12345', - broadcasterId: '999', - ), - ).called(1); - }); - - test('follow retries after integrity failure', () async { - var calls = 0; - when( - () => gqlApi.followUser( - targetId: any(named: 'targetId'), - authToken: any(named: 'authToken'), - integrityToken: any(named: 'integrityToken'), - deviceId: any(named: 'deviceId'), - ), - ).thenAnswer((_) async { - calls++; - if (calls == 1) { - throw const IntegrityCheckException('failed integrity check'); - } - }); - when( - () => gqlApi.fetchIntegrityToken( - authToken: any(named: 'authToken'), - deviceId: any(named: 'deviceId'), - ), - ).thenAnswer( - (_) async => ( - token: 'integrity', - expirationMs: DateTime.now().millisecondsSinceEpoch + 600000, - ), - ); - - final userStore = makeStore(hasModeratorScopes: false); - await userStore.init(); - await userStore.follow(targetId: '999', gqlToken: 'web-token'); - - expect(userStore.cachedIsFollowing('999'), isTrue); - expect(calls, 2); - verify( - () => gqlApi.fetchIntegrityToken( - authToken: 'web-token', - deviceId: any(named: 'deviceId'), - ), - ).called(1); - }); - - test('unfollow updates cache', () async { - when( - () => gqlApi.unfollowUser( - targetId: any(named: 'targetId'), - authToken: any(named: 'authToken'), - integrityToken: any(named: 'integrityToken'), - deviceId: any(named: 'deviceId'), - ), - ).thenAnswer((_) async {}); - - final userStore = makeStore(hasModeratorScopes: false); - await userStore.init(); - await userStore.unfollow(targetId: '999', gqlToken: 'web-token'); - - expect(userStore.cachedIsFollowing('999'), isFalse); - }); - - test('runWithGqlIntegrity refreshes token and retries', () async { - var calls = 0; - when( - () => gqlApi.fetchIntegrityToken( - authToken: any(named: 'authToken'), - deviceId: any(named: 'deviceId'), - ), - ).thenAnswer( - (_) async => ( - token: 'fresh-integrity', - expirationMs: DateTime.now().millisecondsSinceEpoch + 600000, - ), - ); - - final userStore = makeStore(hasModeratorScopes: false); - final result = await userStore.runWithGqlIntegrity( - gqlToken: 'web-token', - action: ({authToken, integrityToken, deviceId}) async { - calls++; - expect(authToken, 'web-token'); - expect(deviceId, isNotEmpty); - if (calls == 1) { - throw const IntegrityCheckException('failed integrity check'); - } - expect(integrityToken, 'fresh-integrity'); - return 'ok'; - }, - ); - - expect(result, 'ok'); - expect(calls, 2); - verify( - () => gqlApi.fetchIntegrityToken( - authToken: 'web-token', - deviceId: any(named: 'deviceId'), - ), - ).called(1); - }); - - test('runWithGqlIntegrity rethrows when no gqlToken', () async { - final userStore = makeStore(hasModeratorScopes: false); - - await expectLater( - userStore.runWithGqlIntegrity( - gqlToken: null, - action: ({authToken, integrityToken, deviceId}) async { - throw const IntegrityCheckException('failed integrity check'); - }, - ), - throwsA(isA()), - ); - verifyNever( - () => gqlApi.fetchIntegrityToken( - authToken: any(named: 'authToken'), - deviceId: any(named: 'deviceId'), - ), - ); - }); - - test('applyWebIntegrity stores device id for later GQL calls', () async { - final userStore = makeStore(hasModeratorScopes: false); - await userStore.applyWebIntegrity( - GqlIntegrityCredentials( - integrityToken: 'v4.public.${base64Url.encode(utf8.encode(jsonEncode({ - 'is_bad_bot': 'false', - 'device_id': 'webview-device', - })))}.sig', - deviceId: 'webview-device', - expirationMs: DateTime.now().millisecondsSinceEpoch + 600000, - ), - ); - - final deviceId = await userStore.runWithGqlIntegrity( - gqlToken: 'web-token', - action: ({authToken, integrityToken, deviceId}) async { - expect(deviceId, 'webview-device'); - expect(integrityToken, isNotNull); - return deviceId!; - }, - ); - expect(deviceId, 'webview-device'); - }); - }); -} diff --git a/test/utils/twitch_oauth_scopes_test.dart b/test/utils/twitch_oauth_scopes_test.dart deleted file mode 100644 index 94723f66..00000000 --- a/test/utils/twitch_oauth_scopes_test.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:frosty/utils/twitch_oauth_scopes.dart'; - -void main() { - group('scope sets', () { - test('core scopes exclude moderator scopes', () { - for (final scope in twitchModeratorScopes) { - expect(twitchUserScopes, isNot(contains(scope))); - } - expect(twitchAllUserScopes, containsAll(twitchUserScopes)); - expect(twitchAllUserScopes, containsAll(twitchModeratorScopes)); - }); - }); - - group('hasModeratorScopes', () { - test('requires all three moderator scopes', () { - expect(hasModeratorScopes(twitchAllUserScopes), isTrue); - expect( - hasModeratorScopes([ - ...twitchUserScopes, - 'user:read:moderated_channels', - 'moderator:manage:chat_messages', - ]), - isFalse, - ); - expect(hasModeratorScopes(twitchUserScopes), isFalse); - }); - }); - - group('missingTwitchScopes', () { - test('defaults to core scopes only', () { - final missing = missingTwitchScopes( - granted: const ['chat:read', 'chat:edit'], - ); - - expect(missing, contains('user:read:follows')); - expect(missing, isNot(contains('user:read:moderated_channels'))); - expect(missing, isNot(contains('chat:read'))); - }); - - test('returns empty when all required scopes are granted', () { - expect(missingTwitchScopes(granted: twitchUserScopes), isEmpty); - expect( - missingTwitchScopes( - granted: twitchAllUserScopes, - required: twitchModeratorScopes, - ), - isEmpty, - ); - }); - }); - - group('unauthorizedDialogMessage', () { - test('explains expired session when logged out', () { - expect( - unauthorizedDialogMessage( - isLoggedIn: false, - grantedScopes: const [], - ), - contains('expired'), - ); - }); - - test('lists missing core permissions when logged in', () { - final message = unauthorizedDialogMessage( - isLoggedIn: true, - grantedScopes: const ['chat:read', 'chat:edit'], - ); - - expect(message, contains('additional Twitch permissions')); - expect(message, contains('View followed channels')); - expect(message, isNot(contains('View channels you moderate'))); - }); - - test('prefers session expired when core scopes are complete', () { - expect( - unauthorizedDialogMessage( - isLoggedIn: true, - grantedScopes: twitchUserScopes, - ), - contains('expired'), - ); - }); - }); - - group('enableModeratorToolsDialogMessage', () { - test('lists moderator permission labels', () { - final message = enableModeratorToolsDialogMessage(); - expect(message, contains('View channels you moderate')); - expect(message, contains('Delete chat messages as a moderator')); - expect(message, contains('Timeout and ban users as a moderator')); - }); - }); -} diff --git a/test/widgets/follow_channel_controller_test.dart b/test/widgets/follow_channel_controller_test.dart deleted file mode 100644 index bdc88570..00000000 --- a/test/widgets/follow_channel_controller_test.dart +++ /dev/null @@ -1,254 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:frosty/apis/base_api_client.dart'; -import 'package:frosty/apis/twitch_api.dart'; -import 'package:frosty/apis/twitch_gql_api.dart'; -import 'package:frosty/apis/twitch_integrity.dart'; -import 'package:frosty/models/user.dart'; -import 'package:frosty/screens/settings/stores/auth_store.dart'; -import 'package:frosty/screens/settings/stores/user_store.dart'; -import 'package:frosty/widgets/follow_channel_controller.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -class MockAuthStore extends Mock implements AuthStore {} - -class MockTwitchApi extends Mock implements TwitchApi {} - -class MockTwitchGqlApi extends Mock implements TwitchGqlApi {} - -void main() { - late MockAuthStore authStore; - late MockTwitchApi api; - late MockTwitchGqlApi gqlApi; - late UserStore userStore; - var notifyCount = 0; - - setUp(() async { - SharedPreferences.setMockInitialValues({}); - authStore = MockAuthStore(); - api = MockTwitchApi(); - gqlApi = MockTwitchGqlApi(); - notifyCount = 0; - - when(() => api.getUserInfo()).thenAnswer( - (_) async => const UserTwitch( - '12345', - 'testuser', - 'TestUser', - 'https://cdn/profile.png', - ), - ); - when(() => api.getUserBlockedList(id: any(named: 'id'))).thenAnswer( - (_) async => [], - ); - - userStore = UserStore( - twitchApi: api, - twitchGqlApi: gqlApi, - hasModeratorScopes: () => false, - ); - await userStore.init(); - - when(() => authStore.user).thenReturn(userStore); - when(() => authStore.isLoggedIn).thenReturn(true); - when(() => authStore.gqlToken).thenReturn('web-token'); - }); - - FollowChannelController makeController({ - String userId = '999', - bool? initialIsFollowing, - }) { - return FollowChannelController( - authStore: authStore, - userId: userId, - initialIsFollowing: initialIsFollowing, - onChanged: () => notifyCount++, - ); - } - - group('FollowChannelController', () { - test('hides when logged out', () { - when(() => authStore.isLoggedIn).thenReturn(false); - final controller = makeController(initialIsFollowing: false); - controller.init(); - - expect(controller.canFollow, isFalse); - expect(controller.shouldShow, isFalse); - }); - - test('hides for own channel', () { - final controller = makeController(userId: '12345'); - controller.init(); - - expect(controller.isSelf, isTrue); - expect(controller.canFollow, isFalse); - expect(controller.shouldShow, isFalse); - }); - - test('uses initialIsFollowing without Helix lookup', () { - final controller = makeController(initialIsFollowing: true); - controller.init(); - - expect(controller.isFollowing, isTrue); - expect(controller.shouldShow, isTrue); - verifyNever( - () => api.isFollowingChannel( - userId: any(named: 'userId'), - broadcasterId: any(named: 'broadcasterId'), - ), - ); - }); - - test('loads follow state from Helix and caches', () async { - when( - () => api.isFollowingChannel( - userId: '12345', - broadcasterId: '999', - ), - ).thenAnswer((_) async => false); - - final controller = makeController(); - controller.init(); - await Future.delayed(Duration.zero); - - expect(controller.isFollowing, isFalse); - expect(controller.loading, isFalse); - expect(controller.shouldShow, isTrue); - expect(userStore.cachedIsFollowing('999'), isFalse); - }); - - test('toggle follows then unfollows', () async { - when( - () => gqlApi.followUser( - targetId: any(named: 'targetId'), - authToken: any(named: 'authToken'), - integrityToken: any(named: 'integrityToken'), - deviceId: any(named: 'deviceId'), - ), - ).thenAnswer((_) async {}); - when( - () => gqlApi.unfollowUser( - targetId: any(named: 'targetId'), - authToken: any(named: 'authToken'), - integrityToken: any(named: 'integrityToken'), - deviceId: any(named: 'deviceId'), - ), - ).thenAnswer((_) async {}); - - final controller = makeController(initialIsFollowing: false); - controller.init(); - - expect(await controller.toggle(), isTrue); - expect(controller.isFollowing, isTrue); - expect(userStore.cachedIsFollowing('999'), isTrue); - - expect(await controller.toggle(), isFalse); - expect(controller.isFollowing, isFalse); - expect(userStore.cachedIsFollowing('999'), isFalse); - }); - - test('toggle throws when web session missing', () async { - when(() => authStore.gqlToken).thenReturn(null); - final controller = makeController(initialIsFollowing: false); - controller.init(); - - await expectLater( - controller.toggle(), - throwsA(isA()), - ); - }); - - test('toggle rethrows integrity and clears action flag', () async { - when( - () => gqlApi.followUser( - targetId: any(named: 'targetId'), - authToken: any(named: 'authToken'), - integrityToken: any(named: 'integrityToken'), - deviceId: any(named: 'deviceId'), - ), - ).thenThrow(const IntegrityCheckException('failed integrity check')); - when( - () => gqlApi.fetchIntegrityToken( - authToken: any(named: 'authToken'), - deviceId: any(named: 'deviceId'), - ), - ).thenThrow(const IntegrityCheckException('bad bot')); - - final controller = makeController(initialIsFollowing: false); - controller.init(); - - await expectLater( - controller.toggle(), - throwsA(isA()), - ); - expect(controller.actionInProgress, isFalse); - expect(controller.isFollowing, isFalse); - }); - - test('retryAfterIntegrity applies credentials and follows', () async { - when( - () => gqlApi.followUser( - targetId: any(named: 'targetId'), - authToken: any(named: 'authToken'), - integrityToken: any(named: 'integrityToken'), - deviceId: any(named: 'deviceId'), - ), - ).thenAnswer((_) async {}); - - final controller = makeController(initialIsFollowing: false); - controller.init(); - - final okToken = - 'v4.public.${base64Url.encode(utf8.encode(jsonEncode({ - 'is_bad_bot': 'false', - 'device_id': 'device-abc', - })))}.sig'; - - final nowFollowing = await controller.retryAfterIntegrity( - wasFollowing: false, - credentials: GqlIntegrityCredentials( - integrityToken: okToken, - deviceId: 'device-abc', - expirationMs: DateTime.now().millisecondsSinceEpoch + 600000, - ), - ); - - expect(nowFollowing, isTrue); - expect(controller.isFollowing, isTrue); - verify( - () => gqlApi.followUser( - targetId: '999', - authToken: 'web-token', - integrityToken: okToken, - deviceId: 'device-abc', - ), - ).called(1); - }); - - test('retryAfterIntegrity rejects bad_bot integrity', () async { - final controller = makeController(initialIsFollowing: false); - controller.init(); - - final badToken = - 'v4.public.${base64Url.encode(utf8.encode(jsonEncode({ - 'is_bad_bot': 'true', - 'device_id': 'device-abc', - })))}.sig'; - - await expectLater( - controller.retryAfterIntegrity( - wasFollowing: false, - credentials: GqlIntegrityCredentials( - integrityToken: badToken, - deviceId: 'device-abc', - expirationMs: DateTime.now().millisecondsSinceEpoch + 600000, - ), - ), - throwsA(isA()), - ); - expect(controller.actionInProgress, isFalse); - }); - }); -} diff --git a/web/.gitignore b/web/.gitignore index f51043ee..a399f275 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -45,4 +45,3 @@ next-env.d.ts # ai .claude/settings.local.json -.playwright-mcp diff --git a/web/AGENTS.md b/web/AGENTS.md index 7926777d..a53f397c 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -1,19 +1,5 @@ # AGENTS.md ---- - -# Author Preferences - -## Infrastructure Checklist - -When creating new infrastructure (routes, API handlers, providers), use exploration findings as a **checklist** — systematically verify each convention is followed before writing code. - ---- - -# Project - -Next.js 16 landing page for [Frosty](https://frostyapp.io) using the App Router with React 19. Deployed on **Cloudflare Workers** via `@opennextjs/cloudflare`. - ## Commands ```bash @@ -31,25 +17,25 @@ pnpm clean # Delete .next, .open-next, and node_modules pnpm nuke # Delete .next, .open-next, node_modules, and pnpm-lock.yaml ``` -## Source Structure +## Architecture -- `src/app/` - App Router pages and layouts -- `src/components/` - React components (`ui/` subdirectory for shadcn `base-vega` style — add with `pnpm dlx shadcn@latest add `) -- `src/lib/` - Utilities (`cn()` for className merging, `constants.ts` for external links) -- `src/assets/` - Static images (screenshots) +Next.js 16 landing page for [Frosty](https://frostyapp.io) using the App Router with React 19. Deployed on **Cloudflare Workers** via `@opennextjs/cloudflare`. -## Key Configuration +### Key Configuration - **React Compiler**: Enabled in `next.config.ts` for automatic memoization - **Typed Routes**: Enabled for type-safe navigation (use `Route` type from `next/navigation`) - **Path Alias**: `@/*` maps to `./src/*` - **Strict TypeScript**: `noUncheckedIndexedAccess`, `noImplicitReturns`, `noFallthroughCasesInSwitch`, `noImplicitOverride`, `verbatimModuleSyntax` -## Cache Components +### Source Structure -Not yet enabled — `cacheComponents` is not set in `next.config.ts` and no `"use cache"` directives are in use. Everything is dynamic (SSR) by default. To opt in: add `cacheComponents: true` to `next.config.ts`, use `"use cache"` + `cacheLife()` on server components/functions, and wrap async work in `` for PPR. Durable runtime cache requires R2 incremental cache (see `open-next.config.ts`) — without it, cache is in-memory only per Worker instance. +- `src/app/` - App Router pages and layouts +- `src/components/` - React components (`ui/` subdirectory for shadcn `base-vega` style — add with `pnpm dlx shadcn@latest add `) +- `src/lib/` - Utilities (`cn()` for className merging, `constants.ts` for external links) +- `src/assets/` - Static images (screenshots) -## Cloudflare Workers +### Cloudflare Workers - `wrangler.jsonc` - Cloudflare Workers configuration (bindings, R2, KV, etc.) - `open-next.config.ts` - OpenNext adapter config (ISR requires uncommenting R2 incremental cache) @@ -81,12 +67,9 @@ Notable strict rules enforced: - `react-you-might-not-need-an-effect` - Avoid unnecessary useEffect - Unused variables must be prefixed with `_` -## Gotchas +## Commits -- **shadcn uses @base-ui/react**: Not Radix UI — component primitives differ from older shadcn examples. `button.tsx` is a `'use client'` module with no `asChild` prop. Check `src/components/ui/` before building custom UI. -- **`useSearchParams()` needs Suspense**: Always wrap components using `useSearchParams()` in a `` boundary — required for production builds. -- **Never remove `tw-animate-css`**: Required by shadcn/ui components for animations. Check shadcn dependencies before removing any package. -- **No `pnpm` prefix inside package.json scripts**: The package manager is already the script runner. Use bare commands (e.g., `next build`, not `pnpm next build`). +Lowercase, no prefixes (e.g., `remove marquee emote background from hero section`). Keep commits tightly scoped. ## Dev Tooling diff --git a/web/next.config.ts b/web/next.config.ts index a5560aa1..5e6a6af3 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -7,10 +7,6 @@ if (process.env.NODE_ENV === 'development') { } const nextConfig: NextConfig = { - // TODO: re-enable image optimization once free transformation quota resets - images: { - unoptimized: true, - }, typedRoutes: true, reactCompiler: true, logging: { diff --git a/web/package.json b/web/package.json index 64276b4f..97f062ab 100644 --- a/web/package.json +++ b/web/package.json @@ -36,6 +36,7 @@ "@eslint/js": "^9.39.2", "@opennextjs/cloudflare": "^1.16.5", "@tailwindcss/postcss": "^4.1.18", + "@tailwindcss/typography": "^0.5.19", "@types/node": "^25.2.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -49,6 +50,7 @@ "prettier": "^3.8.1", "prettier-plugin-organize-imports": "^4.3.0", "prettier-plugin-tailwindcss": "^0.7.2", + "react-scan": "^0.5.3", "shadcn": "^3.8.5", "tailwindcss": "^4.1.18", "typescript": "^5.9.3", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 9096a24e..1fe8ba8e 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@base-ui/react': specifier: ^1.2.0 - version: 1.5.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -19,101 +19,107 @@ importers: version: 2.1.1 lucide-react: specifier: ^0.574.0 - version: 0.574.0(react@19.2.7) + version: 0.574.0(react@19.2.4) motion: specifier: ^12.34.1 - version: 12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 12.34.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next: specifier: ^16.1.6 - version: 16.2.9(@babel/core@7.29.7)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-themes: specifier: ^0.4.6 - version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: ^19.2.4 - version: 19.2.7 + version: 19.2.4 react-dom: specifier: ^19.2.4 - version: 19.2.7(react@19.2.7) + version: 19.2.4(react@19.2.4) react-icons: specifier: ^5.5.0 - version: 5.6.0(react@19.2.7) + version: 5.5.0(react@19.2.4) sonner: specifier: ^2.0.7 - version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tailwind-merge: specifier: ^3.4.1 - version: 3.6.0 + version: 3.4.1 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 devDependencies: '@eslint-react/eslint-plugin': specifier: ^2.12.4 - version: 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + version: 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@eslint/js': specifier: ^9.39.2 - version: 9.39.4 + version: 9.39.2 '@opennextjs/cloudflare': specifier: ^1.16.5 - version: 1.19.11(next@16.2.9(@babel/core@7.29.7)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(wrangler@4.100.0) + version: 1.16.5(next@16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(wrangler@4.66.0) '@tailwindcss/postcss': specifier: ^4.1.18 - version: 4.3.1 + version: 4.1.18 + '@tailwindcss/typography': + specifier: ^0.5.19 + version: 0.5.19(tailwindcss@4.1.18) '@types/node': specifier: ^25.2.3 - version: 25.9.3 + version: 25.2.3 '@types/react': specifier: ^19.2.14 - version: 19.2.17 + version: 19.2.14 '@types/react-dom': specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.17) + version: 19.2.3(@types/react@19.2.14) babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 eslint: specifier: ^9.39.2 - version: 9.39.4(jiti@2.7.0) + version: 9.39.2(jiti@2.6.1) eslint-config-next: specifier: ^16.1.6 - version: 16.2.9(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + version: 16.1.6(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.4(jiti@2.7.0)) + version: 10.1.8(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-you-might-not-need-an-effect: specifier: ^0.9.1 - version: 0.9.3(eslint@9.39.4(jiti@2.7.0)) + version: 0.9.1(eslint@9.39.2(jiti@2.6.1)) next-devtools-mcp: specifier: ^0.3.10 version: 0.3.10 postcss: specifier: ^8.5.6 - version: 8.5.15 + version: 8.5.6 prettier: specifier: ^3.8.1 - version: 3.8.4 + version: 3.8.1 prettier-plugin-organize-imports: specifier: ^4.3.0 - version: 4.3.0(prettier@3.8.4)(typescript@5.9.3) + version: 4.3.0(prettier@3.8.1)(typescript@5.9.3) prettier-plugin-tailwindcss: specifier: ^0.7.2 - version: 0.7.4(prettier-plugin-organize-imports@4.3.0(prettier@3.8.4)(typescript@5.9.3))(prettier@3.8.4) + version: 0.7.2(prettier-plugin-organize-imports@4.3.0(prettier@3.8.1)(typescript@5.9.3))(prettier@3.8.1) + react-scan: + specifier: ^0.5.3 + version: 0.5.3(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) shadcn: specifier: ^3.8.5 - version: 3.8.5(@types/node@25.9.3)(typescript@5.9.3) + version: 3.8.5(@types/node@25.2.3)(typescript@5.9.3) tailwindcss: specifier: ^4.1.18 - version: 4.3.1 + version: 4.1.18 typescript: specifier: ^5.9.3 version: 5.9.3 typescript-eslint: specifier: ^8.56.0 - version: 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + version: 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) wrangler: specifier: ^4.66.0 - version: 4.100.0 + version: 4.66.0 packages: @@ -206,10 +212,6 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/checksums@3.1000.5': - resolution: {integrity: sha512-zOXUUnilC6lgCsQtp77p/QNPmRlTES9Xi6tlDwbR6kfC/kz5PCzZckgHWm5z+8DskdwuMAbFDq61x3zr10GEEQ==} - engines: {node: '>=20.0.0'} - '@aws-sdk/client-cloudfront@3.984.0': resolution: {integrity: sha512-couDuDLpJtoeWne/nYyJ+I+5ntBVdNgBVRTCoDaXuVV7OC3u/wz5Ps0+GogspEwMLEFoOJ8t691h3YXQtnpQTw==} engines: {node: '>=20.0.0'} @@ -230,297 +232,308 @@ packages: resolution: {integrity: sha512-TDvHpOUWlpanc3xQ5Xw0y8L2hoojBFCCSmXQ/6rKqGOf1ScX3dMA+K9aF0Zp0iwjhSh4VvsHD42esl8XwQZDjA==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.974.20': - resolution: {integrity: sha512-7sDi2B2N3mc3nf1nz6FyEx/FCrJ1N1QnBmraHHQNabFaeAh2IaOOLml48/rHOD1bICHgTRkbBgNTvUzEr5Z35g==} + '@aws-sdk/client-sso@3.990.0': + resolution: {integrity: sha512-xTEaPjZwOqVjGbLOP7qzwbdOWJOo1ne2mUhTZwEBBkPvNk4aXB/vcYwWwrjoSWUqtit4+GDbO75ePc/S6TUJYQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.46': - resolution: {integrity: sha512-+GPXVS2srMOlH74S+SmC1gVuP2TvUZ0siuC0onKO93q+udP+M72dmY8wJfVQ5CX9z/9X5A1HHwz5yRIGBtskvQ==} + '@aws-sdk/core@3.973.10': + resolution: {integrity: sha512-4u/FbyyT3JqzfsESI70iFg6e2yp87MB5kS2qcxIA66m52VSTN1fvuvbCY1h/LKq1LvuxIrlJ1ItcyjvcKoaPLg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.48': - resolution: {integrity: sha512-fA5loSdlocacRxyUXtpoHSMuk5rsIKRDzQYVMnMxjcmFeZshaJlJ8lymy/hYKji6sne/UmNGj5pxuEs6kq/Qcg==} + '@aws-sdk/crc64-nvme@3.972.0': + resolution: {integrity: sha512-ThlLhTqX68jvoIVv+pryOdb5coP1cX1/MaTbB9xkGDCbWbsqQcLqzPxuSoW1DCnAAIacmXCWpzUNOB9pv+xXQw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.53': - resolution: {integrity: sha512-ZfdhIOR41q8TcWEnUac+gCOb+O2LBWdHLmjedXpXz4IEFW2ppNuFcm6p0sMTavpM+zD5TYfpH5Gp7guRyqSgsQ==} + '@aws-sdk/credential-provider-env@3.972.8': + resolution: {integrity: sha512-r91OOPAcHnLCSxaeu/lzZAVRCZ/CtTNuwmJkUwpwSDshUrP7bkX1OmFn2nUMWd9kN53Q4cEo8b7226G4olt2Mg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.52': - resolution: {integrity: sha512-9hu2oR0qH7Fst5Tzdx+UWxm+w5zCXtErTLtOOW5hwwQc170CLwOeniRxyFY6s9mHfGEfC5zFukNBdKBwJR8mhQ==} + '@aws-sdk/credential-provider-http@3.972.10': + resolution: {integrity: sha512-DTtuyXSWB+KetzLcWaSahLJCtTUe/3SXtlGp4ik9PCe9xD6swHEkG8n8/BNsQ9dsihb9nhFvuUB4DpdBGDcvVg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.55': - resolution: {integrity: sha512-zMGLa/dhESVqmCD7mmIFFKSwSFrJGScvCXcjvBZEVOOMauFS5JRQvLTMukFpMEFWiV6dTAlsen2ATDBulLPtbg==} + '@aws-sdk/credential-provider-ini@3.972.8': + resolution: {integrity: sha512-n2dMn21gvbBIEh00E8Nb+j01U/9rSqFIamWRdGm/mE5e+vHQ9g0cBNdrYFlM6AAiryKVHZmShWT9D1JAWJ3ISw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.46': - resolution: {integrity: sha512-VUoNFBIjWrUN8NbFiQiuxQEgFjvziAlBRPK+ddh27aj65gk0BYu6bLZnrdrNZwpW6vAihtSUtEMQ1PUJ32QRPA==} + '@aws-sdk/credential-provider-login@3.972.8': + resolution: {integrity: sha512-rMFuVids8ICge/X9DF5pRdGMIvkVhDV9IQFQ8aTYk6iF0rl9jOUa1C3kjepxiXUlpgJQT++sLZkT9n0TMLHhQw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.52': - resolution: {integrity: sha512-nb2/n4o/HQf+FVpVbZe9vCTFngmuDoIsltMgLAtjixaKzvzhB4J8WSDFyWgnErgLHk55ctWH+I4PU+LIHhyffg==} + '@aws-sdk/credential-provider-node@3.972.9': + resolution: {integrity: sha512-LfJfO0ClRAq2WsSnA9JuUsNyIicD2eyputxSlSL0EiMrtxOxELLRG6ZVYDf/a1HCepaYPXeakH4y8D5OLCauag==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.52': - resolution: {integrity: sha512-lKj6aRSGbqLmpYmM24bY7a1Xmfcq2vkE3hv8CSPYfc1yCu0BPu/XEJ1L4Fm61MsU6ULLNSG8UGsffNoFUBjESA==} + '@aws-sdk/credential-provider-process@3.972.8': + resolution: {integrity: sha512-6cg26ffFltxM51OOS8NH7oE41EccaYiNlbd5VgUYwhiGCySLfHoGuGrLm2rMB4zhy+IO5nWIIG0HiodX8zdvHA==} engines: {node: '>=20.0.0'} - '@aws-sdk/dynamodb-codec@3.973.20': - resolution: {integrity: sha512-SFfxiqVgWeIe+RsJJNAMD//2IfehT4bLpGyNJRB0MgHmOIJtdcfMnR1k7KYyaHokSoQVdncVa9O9DIGa4eqcwg==} + '@aws-sdk/credential-provider-sso@3.972.8': + resolution: {integrity: sha512-35kqmFOVU1n26SNv+U37sM8b2TzG8LyqAcd6iM9gprqxyHEh/8IM3gzN4Jzufs3qM6IrH8e43ryZWYdvfVzzKQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/endpoint-cache@3.972.7': - resolution: {integrity: sha512-LkwS3ZOUNL5kHzmz3dDx8lE3HOhZmf2VGjbJ/tMUZJYWWl3J0RJTZM7RFz1MLt06WDVvlShcAjY/RzhYlqLL7g==} + '@aws-sdk/credential-provider-web-identity@3.972.8': + resolution: {integrity: sha512-CZhN1bOc1J3ubQPqbmr5b4KaMJBgdDvYsmEIZuX++wFlzmZsKj1bwkaiTEb5U2V7kXuzLlpF5HJSOM9eY/6nGA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-bucket-endpoint@3.972.24': - resolution: {integrity: sha512-DUGVPyHUdSJ8reJrFTdSkt768EyEbX8quIcd6lqTwr2GqFX2yVocSv66akxFd21VJvdFNdhLU4D+NHCvR6gmOw==} + '@aws-sdk/dynamodb-codec@3.972.11': + resolution: {integrity: sha512-A0oji7TdKmua93ehEmozehNbUzgq912LRdOMx4bviVRo02DFaf9+fnwT4UUvvyIvnMBcFjT39kWP9fvka4dGUg==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-endpoint-discovery@3.972.18': - resolution: {integrity: sha512-1vKJt/6MBB/MBMRM3qzCMdW70syJY8u2DH+dq7yCnPn7wVJmyeAzAa/sK1lIbbYh8BVLbM5FspsT4zbe885gOw==} + '@aws-sdk/endpoint-cache@3.972.2': + resolution: {integrity: sha512-3L7mwqSLJ6ouZZKtCntoNF0HTYDNs1FDQqkGjoPWXcv1p0gnLotaDmLq1rIDqfu4ucOit0Re3ioLyYDUTpSroA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-expect-continue@3.972.20': - resolution: {integrity: sha512-S6sqRv/HhFfv+eCwi+dSIjRTXAxLxyESJIP9giIIVuJAEdi2zFoMd0PdIg65U7JprT7yq0TvHilp2BBDbw8slw==} + '@aws-sdk/middleware-bucket-endpoint@3.972.3': + resolution: {integrity: sha512-fmbgWYirF67YF1GfD7cg5N6HHQ96EyRNx/rDIrTF277/zTWVuPI2qS/ZHgofwR1NZPe/NWvoppflQY01LrbVLg==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-flexible-checksums@3.974.30': - resolution: {integrity: sha512-OaIhub+3yTgfFWPzKO8OzOZFIMUoJaiS5v67y3spQg7SoULGoMx4jKVBbE+uhnzkiZXQ+rEDS0RqrK4/aD1yJw==} + '@aws-sdk/middleware-endpoint-discovery@3.972.3': + resolution: {integrity: sha512-xAxA8/TOygQmMrzcw9CrlpTHCGWSG/lvzrHCySfSZpDN4/yVSfXO+gUwW9WxeskBmuv9IIFATOVpzc9EzfTZ0Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-host-header@3.972.21': - resolution: {integrity: sha512-+SRZL54/T9Ryxa/NmHM7WZAliU6wnfzeosT/+4IVuTgq0zSCpPx2j3yaEP4JFZlvWvoOCbKgr+4tBqSAG/dl5Q==} + '@aws-sdk/middleware-expect-continue@3.972.3': + resolution: {integrity: sha512-4msC33RZsXQpUKR5QR4HnvBSNCPLGHmB55oDiROqqgyOc+TOfVu2xgi5goA7ms6MdZLeEh2905UfWMnMMF4mRg==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-location-constraint@3.972.17': - resolution: {integrity: sha512-DYCppgVKrKRuS+bVJsgQ0N6uARd9YoF1H1rVeTnhHsCp2Ppru9BXb4LdvDQzoKc4fK+MABl3Fz9ck40WBrUsWA==} + '@aws-sdk/middleware-flexible-checksums@3.972.8': + resolution: {integrity: sha512-Hn6gumcN/3/8Fzo9z7N1pA2PRfE8S+qAqdb4g3MqzXjIOIe+VxD7edO/DKAJ1YH11639EGQIHBz0wdOb5btjtw==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-logger@3.972.20': - resolution: {integrity: sha512-8oOvo7XNDeOlwa5ys2Q0UTLCKmtvRiqf8rOU63lKniPmP3dnI5lnoy9dteZ79lxb9hmXCrO28aZMqds6C5AEoA==} + '@aws-sdk/middleware-host-header@3.972.3': + resolution: {integrity: sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-recursion-detection@3.972.22': - resolution: {integrity: sha512-q4H/CoOYrTbyAW0d9RrHf9kTYKVXpAwoK0VEy3UT2Asad+6aa6vzQgz35dh20tRA7zlEo/Nsyjy9PVlHgdq0Vg==} + '@aws-sdk/middleware-location-constraint@3.972.3': + resolution: {integrity: sha512-nIg64CVrsXp67vbK0U1/Is8rik3huS3QkRHn2DRDx4NldrEFMgdkZGI/+cZMKD9k4YOS110Dfu21KZLHrFA/1g==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-sdk-s3@3.972.51': - resolution: {integrity: sha512-keQgcIUTcHL0Qn7guhsuLaxQU36r9norCrxgaPH4DNCwon4TPtXdI/UdYuycl9vj3Dlwc3YR1dfL3U+6iIwJ6w==} + '@aws-sdk/middleware-logger@3.972.3': + resolution: {integrity: sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-sdk-sqs@3.972.30': - resolution: {integrity: sha512-PVAj7VgWK/ZxCXnkgC4B7cdJyUN99Nsr7IEduHt4A1GieuB+ZnU5bSifHwapbr17wrFkmdxfSh+aA0Lj+Ads6w==} + '@aws-sdk/middleware-recursion-detection@3.972.3': + resolution: {integrity: sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-ssec@3.972.17': - resolution: {integrity: sha512-LGpxxtMv/8cQnLeUiZqubqlzSA5ebv+yx+651dq7Sf+KxErRFaj6bdz7Rl9sO8ixvm5+ROfHeZDdNUprVz1etg==} + '@aws-sdk/middleware-sdk-s3@3.972.10': + resolution: {integrity: sha512-wLkB4bshbBtsAiC2WwlHzOWXu1fx3ftL63fQl0DxEda48Q6B8bcHydZppE3KjEIpPyiNOllByfSnb07cYpIgmw==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-user-agent@3.972.50': - resolution: {integrity: sha512-5JIDcDFWy3DUW95sOlXLbeb7RkGFUcNh1QidKsznqtlm5YGsXP0EGWaqzxBTvVmOhqKs2RmNmI6w9V/5dS3CLQ==} + '@aws-sdk/middleware-sdk-sqs@3.972.7': + resolution: {integrity: sha512-DcJLYE4sRjgUyb2SupQGaRgBYc+j89N9nXeMT0PwwVvaBGmKqcxa7PFvz0kBnQrBckPWlfrPyyyMwOeT5BEp6Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.997.20': - resolution: {integrity: sha512-IYJuLpXp2DEILVQpQOy0PMpkftv0AHEOCn52o0atyOaumA0CdWQ3klPyXdViGYLbNpESsVFMVybvHUeZAuiGxA==} + '@aws-sdk/middleware-ssec@3.972.3': + resolution: {integrity: sha512-dU6kDuULN3o3jEHcjm0c4zWJlY1zWVkjG9NPe9qxYLLpcbdj5kRYBS2DdWYD+1B9f910DezRuws7xDEqKkHQIg==} engines: {node: '>=20.0.0'} - '@aws-sdk/region-config-resolver@3.972.24': - resolution: {integrity: sha512-WY6uVMsq0EvxY4BcYhZmG2Ivd1EzNvZAqsXFlL3pTPMG0P4J83TYVQIs8P0nd5lc+Bp3llrYwggruvXzrfUtsQ==} + '@aws-sdk/middleware-user-agent@3.972.10': + resolution: {integrity: sha512-bBEL8CAqPQkI91ZM5a9xnFAzedpzH6NYCOtNyLarRAzTUTFN2DKqaC60ugBa7pnU1jSi4mA7WAXBsrod7nJltg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.990.0': + resolution: {integrity: sha512-3NA0s66vsy8g7hPh36ZsUgO4SiMyrhwcYvuuNK1PezO52vX3hXDW4pQrC6OQLGKGJV0o6tbEyQtXb/mPs8zg8w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/region-config-resolver@3.972.3': + resolution: {integrity: sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==} engines: {node: '>=20.0.0'} '@aws-sdk/signature-v4-multi-region@3.984.0': resolution: {integrity: sha512-TaWbfYCwnuOSvDSrgs7QgoaoXse49E7LzUkVOUhoezwB7bkmhp+iojADm7UepCEu4021SquD7NG1xA+WCvmldA==} engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.996.34': - resolution: {integrity: sha512-mx1L5qlumSOt/nKM3BFaHE2HVkWwz0i4Bw0pyYO42FfX/FeLlo8YI6csC0gSPprEk6fTIqI+CZN9RwUwKd5krQ==} + '@aws-sdk/token-providers@3.990.0': + resolution: {integrity: sha512-L3BtUb2v9XmYgQdfGBzbBtKMXaP5fV973y3Qdxeevs6oUTVXFmi/mV1+LnScA/1wVPJC9/hlK+1o5vbt7cG7EQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1066.0': - resolution: {integrity: sha512-UqEUJq7dqa44hneLDUcX7UJy95cg8YqEWyakRpvIPnrNS3Mq+UlQHgCDGu5pvwAPtlIW4qcYbvW6reG6++FyvA==} + '@aws-sdk/types@3.973.1': + resolution: {integrity: sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==} engines: {node: '>=20.0.0'} - '@aws-sdk/types@3.973.12': - resolution: {integrity: sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA==} + '@aws-sdk/util-arn-parser@3.972.2': + resolution: {integrity: sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg==} engines: {node: '>=20.0.0'} '@aws-sdk/util-endpoints@3.984.0': resolution: {integrity: sha512-9ebjLA0hMKHeVvXEtTDCCOBtwjb0bOXiuUV06HNeVdgAjH6gj4x4Zwt4IBti83TiyTGOCl5YfZqGx4ehVsasbQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-locate-window@3.965.7': - resolution: {integrity: sha512-M0D6oIpohdNHjc7udzTHEQyot0+0iuA36jc2I9Hps+f/GtKi2HO/pyijQnCnNcwZqLB5+rtn81z3eZK/GyjAmA==} + '@aws-sdk/util-endpoints@3.990.0': + resolution: {integrity: sha512-kVwtDc9LNI3tQZHEMNbkLIOpeDK8sRSTuT8eMnzGY+O+JImPisfSTjdh+jw9OTznu+MYZjQsv0258sazVKunYg==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-user-agent-browser@3.972.21': - resolution: {integrity: sha512-Y9MPH4VZaIebwxiVK6dMzSt5L04oyNiK3NNwFe4qP5B2Hfo+pmEVpSJSa+gARPtcJeRyehUMPu5/I9DLdW0cBg==} - - '@aws-sdk/util-user-agent-node@3.973.36': - resolution: {integrity: sha512-wsmSmHTrK9lV+5SKBekp1J7W6PufdZE08X0xv5Lz1OdgYRmyuYDy/++SslKdXhcVQjxLtzMTZaLqqLZmTx6OaQ==} + '@aws-sdk/util-locate-window@3.965.4': + resolution: {integrity: sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==} engines: {node: '>=20.0.0'} - '@aws-sdk/xml-builder@3.972.29': - resolution: {integrity: sha512-fk0niuGFxfi8yIJuMVM4mhwObkiQSuwZFj3tAPrLVx64Pk3BkrEIpqjzHKY4hKoEBUD6Jg/S74Zj9jy+5F3DnQ==} + '@aws-sdk/util-user-agent-browser@3.972.3': + resolution: {integrity: sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==} + + '@aws-sdk/util-user-agent-node@3.972.8': + resolution: {integrity: sha512-XJZuT0LWsFCW1C8dEpPAXSa7h6Pb3krr2y//1X0Zidpcl0vmgY5nL/X0JuBZlntpBzaN3+U4hvKjuijyiiR8zw==} + engines: {node: '>=20.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/xml-builder@3.972.4': + resolution: {integrity: sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==} engines: {node: '>=20.0.0'} - '@aws/lambda-invoke-store@0.2.4': - resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + '@aws/lambda-invoke-store@0.2.3': + resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==} engines: {node: '>=18.0.0'} - '@babel/code-frame@7.29.7': - resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.7': - resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} engines: {node: '>=6.9.0'} - '@babel/core@7.29.7': - resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.7': - resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} - '@babel/helper-annotate-as-pure@7.29.7': - resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.29.7': - resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} - '@babel/helper-create-class-features-plugin@7.29.7': - resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + '@babel/helper-create-class-features-plugin@7.28.6': + resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-globals@7.29.7': - resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} - '@babel/helper-member-expression-to-functions@7.29.7': - resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.29.7': - resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.29.7': - resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-optimise-call-expression@7.29.7': - resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} engines: {node: '>=6.9.0'} - '@babel/helper-plugin-utils@7.29.7': - resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} engines: {node: '>=6.9.0'} - '@babel/helper-replace-supers@7.29.7': - resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-skip-transparent-expression-wrappers@7.29.7': - resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.29.7': - resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.29.7': - resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.29.7': - resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.29.7': - resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.7': - resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} engines: {node: '>=6.0.0'} hasBin: true - '@babel/plugin-syntax-jsx@7.29.7': - resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-typescript@7.29.7': - resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-modules-commonjs@7.29.7': - resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + '@babel/plugin-transform-modules-commonjs@7.28.6': + resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-typescript@7.29.7': - resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==} + '@babel/plugin-transform-typescript@7.28.6': + resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/preset-typescript@7.29.7': - resolution: {integrity: sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==} + '@babel/preset-typescript@7.28.5': + resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/runtime@7.29.7': - resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} - '@babel/template@7.29.7': - resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.7': - resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': - resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@base-ui/react@1.5.0': - resolution: {integrity: sha512-z1gSAlced1yY+iM+mHDEtIkD8UI3Ebs52MuBPxvV6f5hRutk+xvCH/wuB7hDqDzK9JG5FoMz5nhrqtSs1wjt1A==} + '@base-ui/react@1.2.0': + resolution: {integrity: sha512-O6aEQHcm+QyGTFY28xuwRD3SEJGZOBDpyjN2WvpfWYFVhg+3zfXPysAILqtM0C1kWC82MccOE/v1j+GHXE4qIw==} engines: {node: '>=14.0.0'} peerDependencies: - '@date-fns/tz': ^1.2.0 '@types/react': ^17 || ^18 || ^19 - date-fns: ^4.0.0 react: ^17 || ^18 || ^19 react-dom: ^17 || ^18 || ^19 peerDependenciesMeta: - '@date-fns/tz': - optional: true '@types/react': optional: true - date-fns: - optional: true - '@base-ui/utils@0.2.9': - resolution: {integrity: sha512-x/PDDCYzoqPpjrdyb3VcyylTI2IjUXEtYDGi5foh7KsnmNJIIaVwA2GLgDH1dps1GgXiJbA60hM+AyuTfQzIvw==} + '@base-ui/utils@0.2.5': + resolution: {integrity: sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw==} peerDependencies: '@types/react': ^17 || ^18 || ^19 react: ^17 || ^18 || ^19 @@ -529,45 +542,45 @@ packages: '@types/react': optional: true - '@cloudflare/kv-asset-handler@0.5.0': - resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} - engines: {node: '>=22.0.0'} + '@cloudflare/kv-asset-handler@0.4.2': + resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==} + engines: {node: '>=18.0.0'} - '@cloudflare/unenv-preset@2.16.1': - resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + '@cloudflare/unenv-preset@2.13.0': + resolution: {integrity: sha512-bT2rnecesLjDBHgouMEPW9EQ7iLE8OG58srMuCEpAGp75xabi6j124SdS8XZ+dzB3sYBW4iQvVeCTCbAnMMVtA==} peerDependencies: unenv: 2.0.0-rc.24 - workerd: '>1.20260305.0 <2.0.0-0' + workerd: ^1.20260213.0 peerDependenciesMeta: workerd: optional: true - '@cloudflare/workerd-darwin-64@1.20260611.1': - resolution: {integrity: sha512-iJICldmi4sBGgi7IrQles8cStOGXM/Tmv95C4OODVs6VIbMsJPqThUM5h3uYVQNULuJ8I/aVvnJ3Eh/wZCKwuA==} + '@cloudflare/workerd-darwin-64@1.20260217.0': + resolution: {integrity: sha512-t1KRT0j4gwLntixMoNujv/UaS89Q7+MPRhkklaSup5tNhl3zBZOIlasBUSir69eXetqLZu8sypx3i7zE395XXA==} engines: {node: '>=16'} cpu: [x64] os: [darwin] - '@cloudflare/workerd-darwin-arm64@1.20260611.1': - resolution: {integrity: sha512-yBbVXvbZyltR3I7NJdC4C4ItkItjZSiabcA/3HzEWOUQjLVKFqRh4so6ToHr70VCYh8VGeR8EDZL23igLhXqFQ==} + '@cloudflare/workerd-darwin-arm64@1.20260217.0': + resolution: {integrity: sha512-9pEZ15BmELt0Opy79LTxUvbo55QAI4GnsnsvmgBxaQlc4P0dC8iycBGxbOpegkXnRx/LFj51l2zunfTo0EdATg==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] - '@cloudflare/workerd-linux-64@1.20260611.1': - resolution: {integrity: sha512-PfNjpxOlaIgZFYuhD7+neEEewCN2Ud993wEEN0fmbtSOax1AK53LGqmXUDvFhnbkHxJLFAxYCSNISW8QbzaAIg==} + '@cloudflare/workerd-linux-64@1.20260217.0': + resolution: {integrity: sha512-IrZfxQ4b/4/RDQCJsyoxKrCR+cEqKl81yZOirMOKoRrDOmTjn4evYXaHoLBh2PjUKY1Imly7ZiC6G1p0xNIOwg==} engines: {node: '>=16'} cpu: [x64] os: [linux] - '@cloudflare/workerd-linux-arm64@1.20260611.1': - resolution: {integrity: sha512-GEp4XbuIKjlF8pakqXcUDJfKiJosD/Q7S83J0d+r+z9XIlYGfF3ntm08e2aiF5TFTwp3fnG4yMoPUAKNhNJpvQ==} + '@cloudflare/workerd-linux-arm64@1.20260217.0': + resolution: {integrity: sha512-RGU1wq69ym4sFBVWhQeddZrRrG0hJM/SlZ5DwVDga/zBJ3WXxcDsFAgg1dToDfildTde5ySXN7jAasSmWko9rg==} engines: {node: '>=16'} cpu: [arm64] os: [linux] - '@cloudflare/workerd-windows-64@1.20260611.1': - resolution: {integrity: sha512-S6JkS0kEbcCKs19RGqEPhjCRbP8GBkQwqYLp2fhBJtD/KTlwqLzOJ9E6PQ7gQKgWHtxy1NBG3oXarlNFRNU/dw==} + '@cloudflare/workerd-windows-64@1.20260217.0': + resolution: {integrity: sha512-4T65u1321z1Zet9n7liQsSW7g3EXM5SWIT7kJ/uqkEtkPnIzZBIowMQgkvL5W9SpGZks9t3mTQj7hiUia8Gq9Q==} engines: {node: '>=16'} cpu: [x64] os: [win32] @@ -580,27 +593,30 @@ packages: resolution: {integrity: sha512-GeDxvtjiRuoyWVU9nQneId879zIyNdL05bS7RKiqMkfBSKpHMWHLoRyRqjYWLaXmX/llKO1hTlqHDmatkQAjPA==} hasBin: true - '@dotenvx/dotenvx@1.71.3': - resolution: {integrity: sha512-WSmox5aD+XxJEUEOTk7gKLpd5+Iz9Nik89Zpbu5DijMln6LsFiv3xpNKBMc/b9sSkUlKvAblzrhik2TqKFE7NA==} + '@dotenvx/dotenvx@1.52.0': + resolution: {integrity: sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w==} hasBin: true - '@ecies/ciphers@0.2.6': - resolution: {integrity: sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==} - engines: {bun: '>=1', deno: '>=2.7.10', node: '>=16'} + '@ecies/ciphers@0.2.5': + resolution: {integrity: sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==} + engines: {bun: '>=1', deno: '>=2', node: '>=16'} peerDependencies: '@noble/ciphers': ^1.0.0 - '@emnapi/core@1.10.0': - resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + '@emnapi/core@1.8.1': + resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} - '@emnapi/runtime@1.10.0': - resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} - '@emnapi/runtime@1.11.1': - resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} - '@emnapi/wasi-threads@1.2.1': - resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] '@esbuild/aix-ppc64@0.25.4': resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==} @@ -614,6 +630,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.25.4': resolution: {integrity: sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==} engines: {node: '>=18'} @@ -626,6 +648,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.4': resolution: {integrity: sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==} engines: {node: '>=18'} @@ -638,6 +666,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.25.4': resolution: {integrity: sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==} engines: {node: '>=18'} @@ -650,6 +684,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.25.4': resolution: {integrity: sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==} engines: {node: '>=18'} @@ -662,6 +702,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.25.4': resolution: {integrity: sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==} engines: {node: '>=18'} @@ -674,6 +720,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.25.4': resolution: {integrity: sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==} engines: {node: '>=18'} @@ -686,6 +738,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.4': resolution: {integrity: sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==} engines: {node: '>=18'} @@ -698,6 +756,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.25.4': resolution: {integrity: sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==} engines: {node: '>=18'} @@ -710,6 +774,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.25.4': resolution: {integrity: sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==} engines: {node: '>=18'} @@ -722,6 +792,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.25.4': resolution: {integrity: sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==} engines: {node: '>=18'} @@ -734,6 +810,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.4': resolution: {integrity: sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==} engines: {node: '>=18'} @@ -746,6 +828,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.25.4': resolution: {integrity: sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==} engines: {node: '>=18'} @@ -758,6 +846,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.25.4': resolution: {integrity: sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==} engines: {node: '>=18'} @@ -770,6 +864,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.25.4': resolution: {integrity: sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==} engines: {node: '>=18'} @@ -782,6 +882,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.25.4': resolution: {integrity: sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==} engines: {node: '>=18'} @@ -794,6 +900,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.25.4': resolution: {integrity: sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==} engines: {node: '>=18'} @@ -806,6 +918,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.25.4': resolution: {integrity: sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==} engines: {node: '>=18'} @@ -818,6 +936,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.4': resolution: {integrity: sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==} engines: {node: '>=18'} @@ -830,6 +954,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.25.4': resolution: {integrity: sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==} engines: {node: '>=18'} @@ -842,6 +972,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.4': resolution: {integrity: sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==} engines: {node: '>=18'} @@ -854,12 +990,24 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.27.3': resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.4': resolution: {integrity: sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==} engines: {node: '>=18'} @@ -872,6 +1020,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.25.4': resolution: {integrity: sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==} engines: {node: '>=18'} @@ -884,6 +1038,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.25.4': resolution: {integrity: sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==} engines: {node: '>=18'} @@ -896,6 +1056,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.25.4': resolution: {integrity: sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==} engines: {node: '>=18'} @@ -957,8 +1123,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@eslint/config-array@0.21.2': - resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + '@eslint/config-array@0.21.1': + resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/config-helpers@0.4.2': @@ -969,12 +1135,12 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.5': - resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + '@eslint/eslintrc@3.3.3': + resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.39.4': - resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + '@eslint/js@9.39.2': + resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.7': @@ -985,37 +1151,33 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@floating-ui/core@1.7.5': - resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + '@floating-ui/core@1.7.4': + resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} - '@floating-ui/dom@1.7.6': - resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + '@floating-ui/dom@1.7.5': + resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} - '@floating-ui/react-dom@2.1.8': - resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + '@floating-ui/react-dom@2.1.7': + resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' - '@floating-ui/utils@0.2.11': - resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} - '@hono/node-server@1.19.14': - resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + '@hono/node-server@1.19.9': + resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} engines: {node: '>=18.14.1'} peerDependencies: hono: ^4 - '@humanfs/core@0.19.2': - resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} - '@humanfs/node@0.16.8': - resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} - engines: {node: '>=18.18.0'} - - '@humanfs/types@0.15.0': - resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} engines: {node: '>=18.18.0'} '@humanwhocodes/module-importer@1.0.1': @@ -1026,8 +1188,8 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@img/colour@1.1.0': - resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + '@img/colour@1.0.0': + resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} engines: {node: '>=18'} '@img/sharp-darwin-arm64@0.34.5': @@ -1163,41 +1325,49 @@ packages: cpu: [x64] os: [win32] - '@inquirer/ansi@2.0.7': - resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} - '@inquirer/confirm@6.1.1': - resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/core@11.2.1': - resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/figures@2.0.7': - resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} - '@inquirer/type@4.0.7': - resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true + '@isaacs/balanced-match@4.0.1': + resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} + engines: {node: 20 || >=22} + + '@isaacs/brace-expansion@5.0.1': + resolution: {integrity: sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==} + engines: {node: 20 || >=22} + '@isaacs/cliui@9.0.0': resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} engines: {node: '>=18'} @@ -1234,8 +1404,8 @@ packages: '@cfworker/json-schema': optional: true - '@modelcontextprotocol/sdk@1.29.0': - resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + '@modelcontextprotocol/sdk@1.26.0': + resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -1244,66 +1414,63 @@ packages: '@cfworker/json-schema': optional: true - '@mswjs/interceptors@0.41.9': - resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} + '@mswjs/interceptors@0.41.3': + resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} engines: {node: '>=18'} - '@napi-rs/wasm-runtime@1.1.5': - resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} - '@next/env@16.2.9': - resolution: {integrity: sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==} + '@next/env@16.1.6': + resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==} - '@next/eslint-plugin-next@16.2.9': - resolution: {integrity: sha512-UZi8+YT/MLgTC9nrrn2Xd4lBYv1B7lVmtWHfPcthAI5Tt/C1LuDe6DfmtCtJ+WQod3ksY4VrKSvk3oMVAnL7qw==} + '@next/eslint-plugin-next@16.1.6': + resolution: {integrity: sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==} - '@next/swc-darwin-arm64@16.2.9': - resolution: {integrity: sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==} + '@next/swc-darwin-arm64@16.1.6': + resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.9': - resolution: {integrity: sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==} + '@next/swc-darwin-x64@16.1.6': + resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.9': - resolution: {integrity: sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==} + '@next/swc-linux-arm64-gnu@16.1.6': + resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@16.2.9': - resolution: {integrity: sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==} + '@next/swc-linux-arm64-musl@16.1.6': + resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@16.2.9': - resolution: {integrity: sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==} + '@next/swc-linux-x64-gnu@16.1.6': + resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@16.2.9': - resolution: {integrity: sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==} + '@next/swc-linux-x64-musl@16.1.6': + resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@16.2.9': - resolution: {integrity: sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==} + '@next/swc-win32-arm64-msvc@16.1.6': + resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.9': - resolution: {integrity: sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==} + '@next/swc-win32-x64-msvc@16.1.6': + resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1320,9 +1487,6 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} - '@nodable/entities@2.2.0': - resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} - '@node-minify/core@8.0.6': resolution: {integrity: sha512-/vxN46ieWDLU67CmgbArEvOb41zlYFOkOtr9QW9CnTrBLuTyGgkyNWC2y5+khvRw3Br58p2B5ZVSx/PxCTru6g==} engines: {node: '>=16.0.0'} @@ -1354,27 +1518,24 @@ packages: '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} - '@open-draft/deferred-promise@3.0.0': - resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==} - '@open-draft/logger@0.3.0': resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@opennextjs/aws@4.0.2': - resolution: {integrity: sha512-nXQPT8GZDV+NWMJV9mD/Ywxyo+tCZfFvrR6jpEOYNcxK/AqiEDlJzPybGOrHUDWAYdR1b6tmh+MoR3VbqIao3w==} + '@opennextjs/aws@3.9.16': + resolution: {integrity: sha512-jQQStCysIllNCPqz5W2KSguXpr+ETlOcD8SyNu+h9zwpRVYk4uEPQge+ErG3avI5xsT8vKA7EGLYG59dhj/B6Q==} hasBin: true peerDependencies: - next: '>=15.5.18 <16 || >=16.2.6' + next: ~15.0.8 || ~15.1.12 || ~15.2.9 || ~15.3.9 || ~15.4.11 || ~15.5.10 || ~16.0.11 || ^16.1.5 - '@opennextjs/cloudflare@1.19.11': - resolution: {integrity: sha512-spZ7YsZZW9q/OJStaZlJhuklYKei5e2tNQ7PMi3DDSJ7x7167m7EYYHQZfVN5gwJBDkMR2jUDW88oHjnGz4YYw==} + '@opennextjs/cloudflare@1.16.5': + resolution: {integrity: sha512-Ir93CZLrYJJKIVmPcozHILTFjqWuAT8Ur8lLvpWj6+fsBIqMKAOYQr2VoxPCWtp4UYWbiR3iFNwc+d0Eq4I2GQ==} hasBin: true peerDependencies: - next: '>=15.5.18 <16 || >=16.2.6' - wrangler: ^4.86.0 + next: ~15.0.8 || ~15.1.12 || ~15.2.9 || ~15.3.9 || ~15.4.11 || ~15.5.10 || ~16.0.11 || ^16.1.5 + wrangler: ^4.59.2 '@poppinss/colors@4.1.6': resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} @@ -1385,6 +1546,23 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@preact/signals-core@1.13.0': + resolution: {integrity: sha512-slT6XeTCAbdql61GVLlGU4x7XHI7kCZV5Um5uhE4zLX4ApgiiXc0UYFvVOKq06xcovzp7p+61l68oPi563ARKg==} + + '@preact/signals@1.3.4': + resolution: {integrity: sha512-TPMkStdT0QpSc8FpB63aOwXoSiZyIrPsP9Uj347KopdS6olZdAYeeird/5FZv/M1Yc1ge5qstub2o8VDbvkT4g==} + peerDependencies: + preact: 10.x + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -1399,223 +1577,287 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@smithy/config-resolver@4.5.7': - resolution: {integrity: sha512-1mmr3q0viZVm9j4yYqDJFo4Yj+CmLPNYHmxweKrL08xPC1UlUKVawGYKokUlryd9Llgev91D9cWRcDmWeHKvLg==} + '@smithy/abort-controller@4.2.8': + resolution: {integrity: sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==} engines: {node: '>=18.0.0'} - '@smithy/core@3.24.7': - resolution: {integrity: sha512-KoUi4M1f3BG6kzN1FnCwL7oyFptTbyBJKjR6yhSib+JHRdUmM1o+VwsFtJ66NZCkCzVfJMWRHJNo0R0jznp0Pg==} + '@smithy/chunked-blob-reader-native@4.2.1': + resolution: {integrity: sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==} engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.3.9': - resolution: {integrity: sha512-ZlfJ/4Fa3jYb+3eaohPfG9utX9HmdhFNcFtpoGAhUhdynAOmGXtmigbi7eEiONKM+ykHw8RwKuDEb85Lx7t7fA==} + '@smithy/chunked-blob-reader@5.2.0': + resolution: {integrity: sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-browser@4.3.7': - resolution: {integrity: sha512-Ux78G1xlZHYLn8jd8KtMSUo0Hr33cw7lmKdVHWwmPe0TdsR6l9Zx2T9Ljx7kcurSVU9KUwKC4wR4KebbClBWdA==} + '@smithy/config-resolver@4.4.6': + resolution: {integrity: sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-config-resolver@4.4.7': - resolution: {integrity: sha512-OH27/c5hUdDXVdFRBTr/dlJFfFSjccVoxSJ1p0QdDK6xP/v0cE7N2GtAuq1h/BQ5TFCXoeEmDnvcS7fwW8/+1g==} + '@smithy/core@3.23.0': + resolution: {integrity: sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-node@4.3.7': - resolution: {integrity: sha512-zo8M9AzKmMsLUS9zEGo2UIW3odKCpHQXD7WSvRFBTbD8d+kn5iBphsinNyVe3XtdSoSs1XF8qDxbznW+PsZeBg==} + '@smithy/credential-provider-imds@4.2.8': + resolution: {integrity: sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==} engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@5.4.7': - resolution: {integrity: sha512-NslaM2ir0N2hisDmzXLstPaVINZheh8SokyOC++kzFPloZucL2R7Y7bS57mSzx/1Fc/fqmn7twjkeezTTrV0EA==} + '@smithy/eventstream-codec@4.2.8': + resolution: {integrity: sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==} engines: {node: '>=18.0.0'} - '@smithy/hash-blob-browser@4.3.7': - resolution: {integrity: sha512-JcP/TiSUBrHZjrY1z/jwiSxBJgkJMwYdqkyXHilg/Riybnm7hH0YQfBPk4y+09efz+BPZEBemaxAOETxPRpdLg==} + '@smithy/eventstream-serde-browser@4.2.8': + resolution: {integrity: sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==} engines: {node: '>=18.0.0'} - '@smithy/hash-node@4.3.7': - resolution: {integrity: sha512-92AAv0TrUw6nVbKZPiGe+DmfY8CLo6fLh0Xz6dUC75bKviYZSIUMAquC4VTR9fjXlxh+NX+3SqyHhmGVcAqkAg==} + '@smithy/eventstream-serde-config-resolver@4.3.8': + resolution: {integrity: sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==} engines: {node: '>=18.0.0'} - '@smithy/hash-stream-node@4.3.7': - resolution: {integrity: sha512-h/sJCJiB7HmxnuiEgbqZGsULO4yHrK6zqSx2Cyu14aONhTi6klMoqcR1WkkjMlul3wVADj5eAu2mptcnV0BsXA==} + '@smithy/eventstream-serde-node@4.2.8': + resolution: {integrity: sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==} engines: {node: '>=18.0.0'} - '@smithy/invalid-dependency@4.3.7': - resolution: {integrity: sha512-oUWWs71R3+VjlZJU6IBcLdN/vvH7jbJ11Dy7m73bQoVcbFuuVQGoVrL3IHcSpaIsuWgEQPziKBSVzTd4Uhr+5w==} + '@smithy/eventstream-serde-universal@4.2.8': + resolution: {integrity: sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.3.9': + resolution: {integrity: sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-blob-browser@4.2.9': + resolution: {integrity: sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-node@4.2.8': + resolution: {integrity: sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-stream-node@4.2.8': + resolution: {integrity: sha512-v0FLTXgHrTeheYZFGhR+ehX5qUm4IQsjAiL9qehad2cyjMWcN2QG6/4mSwbSgEQzI7jwfoXj7z4fxZUx/Mhj2w==} + engines: {node: '>=18.0.0'} + + '@smithy/invalid-dependency@4.2.8': + resolution: {integrity: sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==} engines: {node: '>=18.0.0'} '@smithy/is-array-buffer@2.2.0': resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} engines: {node: '>=14.0.0'} - '@smithy/md5-js@4.3.7': - resolution: {integrity: sha512-l1QeGbpVKzVhTxqY8aUMfLyVZ3D7QLUaiEVF8zqOZTCOiMSIIRH+PIzzBGGMECitXUwt0ZF/FFETamd3pNH+lw==} + '@smithy/is-array-buffer@4.2.0': + resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==} engines: {node: '>=18.0.0'} - '@smithy/middleware-content-length@4.3.7': - resolution: {integrity: sha512-LvpbwYfDyDg3ez8qM+0+aoo+tZvG1+cJR2DLfSJn0feDBMevQwU/B85OBjnGGwyxhOFJXiGriqt1Qn/9mS8T3A==} + '@smithy/md5-js@4.2.8': + resolution: {integrity: sha512-oGMaLj4tVZzLi3itBa9TCswgMBr7k9b+qKYowQ6x1rTyTuO1IU2YHdHUa+891OsOH+wCsH7aTPRsTJO3RMQmjQ==} engines: {node: '>=18.0.0'} - '@smithy/middleware-endpoint@4.5.7': - resolution: {integrity: sha512-rs+xOLEA/i5O/VWrtxjw8Wydk4gt0TcG1XBfWcSKpg41W+yWrkHuQUFrID5X+199P2DOnLoRcLmANU6IqqAKow==} + '@smithy/middleware-content-length@4.2.8': + resolution: {integrity: sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==} engines: {node: '>=18.0.0'} - '@smithy/middleware-retry@4.6.7': - resolution: {integrity: sha512-TH2Gw3wp+1iMMwvwpfX0otsnW0c91QttO2UmtftVpZvMH5c3QFOstTmMUXtnEii9CydT/xJno4leduaXMK2D4w==} + '@smithy/middleware-endpoint@4.4.14': + resolution: {integrity: sha512-FUFNE5KVeaY6U/GL0nzAAHkaCHzXLZcY1EhtQnsAqhD8Du13oPKtMB9/0WK4/LK6a/T5OZ24wPoSShff5iI6Ag==} engines: {node: '>=18.0.0'} - '@smithy/middleware-serde@4.3.7': - resolution: {integrity: sha512-5z8w1mTB1J4jYIFpi69wOCTPMyxeggrDcz/S3KlPWOCs0PkazW33G2mAKu/b5CMN12NpZtUH4BDbYt1IfJ0FHA==} + '@smithy/middleware-retry@4.4.31': + resolution: {integrity: sha512-RXBzLpMkIrxBPe4C8OmEOHvS8aH9RUuCOH++Acb5jZDEblxDjyg6un72X9IcbrGTJoiUwmI7hLypNfuDACypbg==} engines: {node: '>=18.0.0'} - '@smithy/middleware-stack@4.3.7': - resolution: {integrity: sha512-xtA670qWcsZA1jBDMIvBz3aYm7DQteW9IzCjsFNLfSrGeEiCIRSfHug5MzJo66qyHdZKSidU+tjtTOIeldqI7Q==} + '@smithy/middleware-serde@4.2.9': + resolution: {integrity: sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==} engines: {node: '>=18.0.0'} - '@smithy/node-config-provider@4.4.7': - resolution: {integrity: sha512-GbLL8AyUmsYYmWBNa4YU4vlSDG2kBeLp6Sevh20dvnfRYsgl+eGR0dv9X5wKFDURiVZCruz6OgfM1AQYOHM04Q==} + '@smithy/middleware-stack@4.2.8': + resolution: {integrity: sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==} engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.7.8': - resolution: {integrity: sha512-f+DbsWUwSbtMu1a/j8Y93KiU1SRg9nyzfjereqn1BJ33QOTUXxdlYvVXMhAYl1vuR1Kmna5aIJe09KSIfyFNYw==} + '@smithy/node-config-provider@4.3.8': + resolution: {integrity: sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==} engines: {node: '>=18.0.0'} - '@smithy/protocol-http@5.4.7': - resolution: {integrity: sha512-C4SfAC15LwAvH6xzt/KklMPUDiTdxHH3fu9+qFeuve8j+pHQyT7pF9/Vot9HOheARmxf8bHCSxIB4kFHP/hKYg==} + '@smithy/node-http-handler@4.4.10': + resolution: {integrity: sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA==} engines: {node: '>=18.0.0'} - '@smithy/signature-v4@5.4.7': - resolution: {integrity: sha512-LwQZazFayImv+IOm0S0enoLeUJwmAlhGC5O6YCcLWezyu08dF46GOxPOq35OpBIHkgd7OvNvBStIFwVNyrvoBw==} + '@smithy/property-provider@4.2.8': + resolution: {integrity: sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==} engines: {node: '>=18.0.0'} - '@smithy/smithy-client@4.13.7': - resolution: {integrity: sha512-kNOS4kEILDeE9Qpp7/rGSE/VMUOhVRan3g0zDj+hFB4Ka9JJ99hJHJ0mI5x5qhAAi/fZA5DNbVKB0+RWX9UGGw==} + '@smithy/protocol-http@5.3.8': + resolution: {integrity: sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==} engines: {node: '>=18.0.0'} - '@smithy/types@4.14.4': - resolution: {integrity: sha512-B2S9+UGm1+/pHkcx3ZoLVX1a+pmSk8rqxRR+ZsNqZaJ5q9FWX9AFGQVM4qG5+OBeQUZVy99HY8HqW8gK/wgXzQ==} + '@smithy/querystring-builder@4.2.8': + resolution: {integrity: sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==} engines: {node: '>=18.0.0'} - '@smithy/url-parser@4.3.7': - resolution: {integrity: sha512-nAKFs3vUD7IByqFq060Xaq2ct07HkarhVpPJTh2hb23Hczu+cZWXm2AXV0D+1xc/eCksJQCmT1/3SwEYw3LXJw==} + '@smithy/querystring-parser@4.2.8': + resolution: {integrity: sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==} engines: {node: '>=18.0.0'} - '@smithy/util-base64@4.4.7': - resolution: {integrity: sha512-fCRquAqooXwff8PmhPSZatA4V5uBD23b+lX9lXIyNEisTekKVJ6EePEa2rmtopySyD2TpTlsEIcET0eNauQH1w==} + '@smithy/service-error-classification@4.2.8': + resolution: {integrity: sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==} engines: {node: '>=18.0.0'} - '@smithy/util-body-length-browser@4.3.7': - resolution: {integrity: sha512-NNlnEnix+DvaWvfVdJEGUewce5FbZvDfZYI6zl/UJGAJTIn5ZRKD7KXjQODhMq64UxLPajmnOmQM584Ys5ZT0g==} + '@smithy/shared-ini-file-loader@4.4.3': + resolution: {integrity: sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==} engines: {node: '>=18.0.0'} - '@smithy/util-body-length-node@4.3.7': - resolution: {integrity: sha512-C/SdrjNeT2j5rSSHese6Vbg0Yyban3pVWtYaEZ/ZwgwSiFtCrPrLtL0L4yaAPc5+uMVVWSuDf1Fp1jvMq6+kbA==} + '@smithy/signature-v4@5.3.8': + resolution: {integrity: sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==} + engines: {node: '>=18.0.0'} + + '@smithy/smithy-client@4.11.3': + resolution: {integrity: sha512-Q7kY5sDau8OoE6Y9zJoRGgje8P4/UY0WzH8R2ok0PDh+iJ+ZnEKowhjEqYafVcubkbYxQVaqwm3iufktzhprGg==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.12.0': + resolution: {integrity: sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.2.8': + resolution: {integrity: sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-base64@4.3.0': + resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-browser@4.2.0': + resolution: {integrity: sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-node@4.2.1': + resolution: {integrity: sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==} engines: {node: '>=18.0.0'} '@smithy/util-buffer-from@2.2.0': resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} engines: {node: '>=14.0.0'} - '@smithy/util-defaults-mode-browser@4.4.7': - resolution: {integrity: sha512-elDqX7i4Y1rcbebstHoMqSyZDktp7e4pfvqzUC/oCeaP8g/qDliIzwVAKs7tpS1m/C+pJ3THPdb3r9kwI63ycg==} + '@smithy/util-buffer-from@4.2.0': + resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-node@4.3.7': - resolution: {integrity: sha512-Pw2vgWWtaX+c08r4TE1XZFsubVaFUyWv9l0TncGDpoXvuqef5+50gIdOgSF2Uf0Yjvc+r7Oz8AYtfUggXQ3ehg==} + '@smithy/util-config-provider@4.2.0': + resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} engines: {node: '>=18.0.0'} - '@smithy/util-endpoints@3.5.7': - resolution: {integrity: sha512-HOSB9ARkpt2/LzwYMrXpoxlygo58FyIPS+CO0wAI4mAD0E15aQ5t6ZYa9WzSeWPKPed2Kmyl+NAMhsorA1aCLg==} + '@smithy/util-defaults-mode-browser@4.3.30': + resolution: {integrity: sha512-cMni0uVU27zxOiU8TuC8pQLC1pYeZ/xEMxvchSK/ILwleRd1ugobOcIRr5vXtcRqKd4aBLWlpeBoDPJJ91LQng==} engines: {node: '>=18.0.0'} - '@smithy/util-middleware@4.3.7': - resolution: {integrity: sha512-q8P1up/IlZQNqopAHkGtKSpAUVdkacyVR8n8+7BjM89BBGbl6Wtf/2PSCMIYjFXKh2WegvN8ig4t4vX9WhUm8Q==} + '@smithy/util-defaults-mode-node@4.2.33': + resolution: {integrity: sha512-LEb2aq5F4oZUSzWBG7S53d4UytZSkOEJPXcBq/xbG2/TmK9EW5naUZ8lKu1BEyWMzdHIzEVN16M3k8oxDq+DJA==} engines: {node: '>=18.0.0'} - '@smithy/util-retry@4.4.7': - resolution: {integrity: sha512-ijOv9opnYYNtDfi5yIjBUDfPirQiiggAQcKCthEkUofAhQRRu9IQSbhHP1RXGzrQJMgRjCZoLfx+JX5VucUXWQ==} + '@smithy/util-endpoints@3.2.8': + resolution: {integrity: sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==} engines: {node: '>=18.0.0'} - '@smithy/util-stream@4.6.7': - resolution: {integrity: sha512-FSdIw4ju2unfrxVmjbk+mwMJhwFkTZEYwbawd3DQMmG1Qh5MFvd6rUdtHgBP4Tya7BNcCkkZen45n9VxYxwizQ==} + '@smithy/util-hex-encoding@4.2.0': + resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.2.8': + resolution: {integrity: sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==} + engines: {node: '>=18.0.0'} + + '@smithy/util-retry@4.2.8': + resolution: {integrity: sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.5.12': + resolution: {integrity: sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-uri-escape@4.2.0': + resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==} engines: {node: '>=18.0.0'} '@smithy/util-utf8@2.3.0': resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} engines: {node: '>=14.0.0'} - '@smithy/util-utf8@4.3.7': - resolution: {integrity: sha512-U2XPgO73I8ef8FP3jRQO4Iiol7chWuv4TD6LxNLvRo/pYwBvbelxLcODkpAA9ek+k23NMgc9NoiVzp8INwogMA==} + '@smithy/util-utf8@4.2.0': + resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==} engines: {node: '>=18.0.0'} - '@smithy/util-waiter@4.4.7': - resolution: {integrity: sha512-U7tEAhbItBaJXmY4TfsXBR0YzWLuYPSUi1qNxsj44/5wqdzq/7jxor1oBwZMBEkDbJWGIc2J0uSmNtSZEWt6yQ==} + '@smithy/util-waiter@4.2.8': + resolution: {integrity: sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg==} engines: {node: '>=18.0.0'} - '@speed-highlight/core@1.2.17': - resolution: {integrity: sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==} + '@smithy/uuid@1.1.0': + resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} + engines: {node: '>=18.0.0'} + + '@speed-highlight/core@1.2.14': + resolution: {integrity: sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==} '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - '@tailwindcss/node@4.3.1': - resolution: {integrity: sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==} + '@tailwindcss/node@4.1.18': + resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} - '@tailwindcss/oxide-android-arm64@4.3.1': - resolution: {integrity: sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==} - engines: {node: '>= 20'} + '@tailwindcss/oxide-android-arm64@4.1.18': + resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} + engines: {node: '>= 10'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.3.1': - resolution: {integrity: sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==} - engines: {node: '>= 20'} + '@tailwindcss/oxide-darwin-arm64@4.1.18': + resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} + engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.3.1': - resolution: {integrity: sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==} - engines: {node: '>= 20'} + '@tailwindcss/oxide-darwin-x64@4.1.18': + resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} + engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.3.1': - resolution: {integrity: sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==} - engines: {node: '>= 20'} + '@tailwindcss/oxide-freebsd-x64@4.1.18': + resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} + engines: {node: '>= 10'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1': - resolution: {integrity: sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==} - engines: {node: '>= 20'} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': + resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} + engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.3.1': - resolution: {integrity: sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==} - engines: {node: '>= 20'} + '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': + resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} + engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@tailwindcss/oxide-linux-arm64-musl@4.3.1': - resolution: {integrity: sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==} - engines: {node: '>= 20'} + '@tailwindcss/oxide-linux-arm64-musl@4.1.18': + resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} + engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@tailwindcss/oxide-linux-x64-gnu@4.3.1': - resolution: {integrity: sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==} - engines: {node: '>= 20'} + '@tailwindcss/oxide-linux-x64-gnu@4.1.18': + resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} + engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@tailwindcss/oxide-linux-x64-musl@4.3.1': - resolution: {integrity: sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==} - engines: {node: '>= 20'} + '@tailwindcss/oxide-linux-x64-musl@4.1.18': + resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} + engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@tailwindcss/oxide-wasm32-wasi@4.3.1': - resolution: {integrity: sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==} + '@tailwindcss/oxide-wasm32-wasi@4.1.18': + resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -1626,24 +1868,29 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.3.1': - resolution: {integrity: sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==} - engines: {node: '>= 20'} + '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': + resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} + engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.3.1': - resolution: {integrity: sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==} - engines: {node: '>= 20'} + '@tailwindcss/oxide-win32-x64-msvc@4.1.18': + resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} + engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.3.1': - resolution: {integrity: sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==} - engines: {node: '>= 20'} + '@tailwindcss/oxide@4.1.18': + resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} + engines: {node: '>= 10'} - '@tailwindcss/postcss@4.3.1': - resolution: {integrity: sha512-dNJuNbdEJT/SWRuXTYP1WSamelsz3ztkUsdtWQPjrexysrTpaEPM40P/71knXiXLYEojqPOEGitVLLpPMS5T6A==} + '@tailwindcss/postcss@4.1.18': + resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==} + + '@tailwindcss/typography@0.5.19': + resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} @@ -1651,11 +1898,11 @@ packages: '@tsconfig/node18@1.0.3': resolution: {integrity: sha512-RbwvSJQsuN9TB04AQbGULYfOGE/RnSFk/FLQ5b0NmDf5Kx2q/lABZbHQPKCO1vZ6Fiwkplu+yb9pGdLy1iGseQ==} - '@tybys/wasm-util@0.10.2': - resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} - '@types/estree@1.0.9': - resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1669,19 +1916,24 @@ packages: '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} - '@types/node@25.9.3': - resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} + '@types/node@20.19.33': + resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==} + + '@types/node@25.2.3': + resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: '@types/react': ^19.2.0 - '@types/react@19.2.17': - resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/react-reconciler@0.28.9': + resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==} + peerDependencies: + '@types/react': '*' - '@types/set-cookie-parser@2.4.10': - resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -1689,172 +1941,201 @@ packages: '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} - '@typescript-eslint/eslint-plugin@8.61.0': - resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==} + '@typescript-eslint/eslint-plugin@8.56.0': + resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.61.0 + '@typescript-eslint/parser': ^8.56.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.61.0': - resolution: {integrity: sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==} + '@typescript-eslint/parser@8.56.0': + resolution: {integrity: sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.61.0': - resolution: {integrity: sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==} + '@typescript-eslint/project-service@8.55.0': + resolution: {integrity: sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.1.0' + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.61.0': - resolution: {integrity: sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.61.0': - resolution: {integrity: sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==} + '@typescript-eslint/project-service@8.56.0': + resolution: {integrity: sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.1.0' + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.61.0': - resolution: {integrity: sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==} + '@typescript-eslint/scope-manager@8.55.0': + resolution: {integrity: sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/scope-manager@8.56.0': + resolution: {integrity: sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.55.0': + resolution: {integrity: sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/tsconfig-utils@8.56.0': + resolution: {integrity: sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.55.0': + resolution: {integrity: sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.56.0': + resolution: {integrity: sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.61.0': - resolution: {integrity: sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==} + '@typescript-eslint/types@8.55.0': + resolution: {integrity: sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.61.0': - resolution: {integrity: sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==} + '@typescript-eslint/types@8.56.0': + resolution: {integrity: sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.55.0': + resolution: {integrity: sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.1.0' + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.61.0': - resolution: {integrity: sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==} + '@typescript-eslint/typescript-estree@8.56.0': + resolution: {integrity: sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.55.0': + resolution: {integrity: sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.56.0': + resolution: {integrity: sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.61.0': - resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} + '@typescript-eslint/visitor-keys@8.55.0': + resolution: {integrity: sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@unrs/resolver-binding-android-arm-eabi@1.12.2': - resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} + '@typescript-eslint/visitor-keys@8.56.0': + resolution: {integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} cpu: [arm] os: [android] - '@unrs/resolver-binding-android-arm64@1.12.2': - resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==} + '@unrs/resolver-binding-android-arm64@1.11.1': + resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} cpu: [arm64] os: [android] - '@unrs/resolver-binding-darwin-arm64@1.12.2': - resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==} + '@unrs/resolver-binding-darwin-arm64@1.11.1': + resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} cpu: [arm64] os: [darwin] - '@unrs/resolver-binding-darwin-x64@1.12.2': - resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==} + '@unrs/resolver-binding-darwin-x64@1.11.1': + resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} cpu: [x64] os: [darwin] - '@unrs/resolver-binding-freebsd-x64@1.12.2': - resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==} + '@unrs/resolver-binding-freebsd-x64@1.11.1': + resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} cpu: [x64] os: [freebsd] - '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': - resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==} + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} cpu: [arm] os: [linux] - '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': - resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==} + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} cpu: [arm] os: [linux] - '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': - resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - '@unrs/resolver-binding-linux-arm64-musl@1.12.2': - resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': - resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} - cpu: [loong64] - os: [linux] - - '@unrs/resolver-binding-linux-loong64-musl@1.12.2': - resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} - cpu: [loong64] - os: [linux] - - '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': - resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': - resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': - resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': - resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - '@unrs/resolver-binding-linux-x64-gnu@1.12.2': - resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - '@unrs/resolver-binding-linux-x64-musl@1.12.2': - resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - '@unrs/resolver-binding-openharmony-arm64@1.12.2': - resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} - cpu: [arm64] - os: [openharmony] - - '@unrs/resolver-binding-wasm32-wasi@1.12.2': - resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==} + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': - resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==} + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} cpu: [arm64] os: [win32] - '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': - resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==} + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} cpu: [ia32] os: [win32] - '@unrs/resolver-binding-win32-x64-msvc@1.12.2': - resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==} + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} cpu: [x64] os: [win32] @@ -1871,8 +2152,8 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.17.0: - resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} hasBin: true @@ -1892,11 +2173,14 @@ packages: ajv: optional: true - ajv@6.15.0: - resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - ajv@8.20.0: - resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} @@ -1918,13 +2202,10 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} - ansis@4.3.1: - resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} - anynum@1.0.0: - resolution: {integrity: sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==} - argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1940,9 +2221,6 @@ packages: resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} engines: {node: '>= 0.4'} - array-timsort@1.0.3: - resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} - array.prototype.findlast@1.2.5: resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} engines: {node: '>= 0.4'} @@ -1988,8 +2266,8 @@ packages: aws4fetch@1.0.20: resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} - axe-core@4.12.1: - resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==} + axe-core@4.11.1: + resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} engines: {node: '>=4'} axobject-query@4.1.0: @@ -2002,15 +2280,19 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} + balanced-match@4.0.2: + resolution: {integrity: sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==} + engines: {node: 20 || >=22} - baseline-browser-mapping@2.10.37: - resolution: {integrity: sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==} - engines: {node: '>=6.0.0'} + baseline-browser-mapping@2.9.19: + resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true + bippy@0.5.30: + resolution: {integrity: sha512-8CFmJAHD3gmTLDOCDHuWhjm1nxHSFZdlGoWtak9r53Uxn36ynOjxBLyxXHh/7h/XiKLyPvfdXa0gXWcD9o9lLQ==} + peerDependencies: + react: '>=17.0.1' + birecord@0.1.1: resolution: {integrity: sha512-VUpsf/qykW0heRlC8LooCq28Kxn3mAqKohhDG/49rrsQ1dT1CXyj/pgXS+5BSRzFTR/3DyIBOqQOrGyZOh71Aw==} @@ -2024,22 +2306,22 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} - brace-expansion@1.1.15: - resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - brace-expansion@2.1.1: - resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.2: + resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -2058,8 +2340,8 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - call-bind@1.0.9: - resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} engines: {node: '>= 0.4'} call-bound@1.0.4: @@ -2070,8 +2352,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001799: - resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + caniuse-lite@1.0.30001769: + resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} @@ -2081,10 +2363,6 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - ci-info@4.4.0: - resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} - engines: {node: '>=8'} - class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -2147,28 +2425,20 @@ packages: commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} - comment-json@4.6.2: - resolution: {integrity: sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==} - engines: {node: '>= 6'} - compare-versions@6.1.1: resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - content-disposition@1.1.0: - resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} engines: {node: '>=18'} content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} - content-type@2.0.0: - resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} - engines: {node: '>=18'} - convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -2188,8 +2458,8 @@ packages: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} - cosmiconfig@9.0.2: - resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==} + cosmiconfig@9.0.0: + resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} engines: {node: '>=14'} peerDependencies: typescript: '>=4.9.5' @@ -2245,8 +2515,8 @@ packages: supports-color: optional: true - dedent@1.7.2: - resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + dedent@1.7.1: + resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} peerDependencies: babel-plugin-macros: ^3.1.0 peerDependenciesMeta: @@ -2292,8 +2562,8 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - diff@8.0.4: - resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + diff@8.0.3: + resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} doctrine@2.1.0: @@ -2304,8 +2574,8 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} - dotenv@17.4.2: - resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + dotenv@17.3.1: + resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} engines: {node: '>=12'} dunder-proto@1.0.1: @@ -2315,15 +2585,15 @@ packages: duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} - eciesjs@0.4.18: - resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==} + eciesjs@0.4.17: + resolution: {integrity: sha512-TOOURki4G7sD1wDCjj7NfLaXZZ49dFOeEb5y39IXpb8p0hRzVvfvzZHOi5JcT+PpyAbi/Y+lxPb8eTag2WYH8w==} engines: {bun: '>=1', deno: '>=2', node: '>=16'} ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.372: - resolution: {integrity: sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==} + electron-to-chromium@1.5.286: + resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2338,8 +2608,8 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - enhanced-resolve@5.21.6: - resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} + enhanced-resolve@5.19.0: + resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} engines: {node: '>=10.13.0'} enquirer@2.4.1: @@ -2356,8 +2626,8 @@ packages: error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} - es-abstract@1.24.2: - resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + es-abstract@1.24.1: + resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} engines: {node: '>= 0.4'} es-define-property@1.0.1: @@ -2368,12 +2638,12 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-iterator-helpers@1.3.3: - resolution: {integrity: sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==} + es-iterator-helpers@1.2.2: + resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} engines: {node: '>= 0.4'} - es-object-atoms@1.1.2: - resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} es-set-tostringtag@2.1.0: @@ -2388,6 +2658,11 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.25.4: resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==} engines: {node: '>=18'} @@ -2409,8 +2684,8 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-config-next@16.2.9: - resolution: {integrity: sha512-olGtBrs07bQchpaJWeqbk9GaMoU0oGmN/pYNEBXSbfgKngb5uHnPe37X6tVeh6DJfaWFQildvinGEOrolo5fmw==} + eslint-config-next@16.1.6: + resolution: {integrity: sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==} peerDependencies: eslint: '>=9.0.0' typescript: '>=3.3.1' @@ -2424,8 +2699,8 @@ packages: peerDependencies: eslint: '>=7.0.0' - eslint-import-resolver-node@0.3.10: - resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} + eslint-import-resolver-node@0.3.9: + resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} eslint-import-resolver-typescript@3.10.1: resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} @@ -2440,8 +2715,8 @@ packages: eslint-plugin-import-x: optional: true - eslint-module-utils@2.13.0: - resolution: {integrity: sha512-bLohSkT6469rRs8czj0tLTD8vaeIS/whvPRJVjDr7IuoTT1k5DYDERlNycjDj/HkOlvQdYurmfZ/g3fG5bgeLQ==} + eslint-module-utils@2.12.1: + resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} engines: {node: '>=4'} peerDependencies: '@typescript-eslint/parser': '*' @@ -2491,11 +2766,11 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - eslint-plugin-react-hooks@7.1.1: - resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + eslint-plugin-react-hooks@7.0.1: + resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} engines: {node: '>=18'} peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 eslint-plugin-react-naming-convention@2.13.0: resolution: {integrity: sha512-uSd25JzSg2R4p81s3Wqck0AdwRlO9Yc+cZqTEXv7vW8exGGAM3mWnF6hgrgdqVJqBEGJIbS/Vx1r5BdKcY/MHA==} @@ -2525,8 +2800,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - eslint-plugin-react-you-might-not-need-an-effect@0.9.3: - resolution: {integrity: sha512-44cce7LndBnpDRWBTQ8p7ircIdl2rJBP5+V9Ik64E935UB47uA9ZMU1Uv160lAMhtvoPYqXBjQ+tojr5JF3mFQ==} + eslint-plugin-react-you-might-not-need-an-effect@0.9.1: + resolution: {integrity: sha512-P6Zq5hAKc10Zg5V6vusm3+WZc59OHqHeCTTH2soW3r9fBmeJssuHzltIEKtrONXqFwHVWw3bz2qGlYuO2rBk1g==} engines: {node: '>=14.0.0'} peerDependencies: eslint: '>=8.40.0' @@ -2549,12 +2824,12 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint-visitor-keys@5.0.1: - resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + eslint-visitor-keys@5.0.0: + resolution: {integrity: sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@9.39.4: - resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + eslint@9.39.2: + resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -2584,6 +2859,12 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -2596,8 +2877,8 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} - eventsource-parser@3.1.0: - resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} eventsource@3.0.7: @@ -2618,8 +2899,8 @@ packages: peerDependencies: express: '>= 4.11' - express-rate-limit@8.5.2: - resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + express-rate-limit@8.2.1: + resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' @@ -2645,23 +2926,11 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-string-truncated-width@3.0.3: - resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - fast-string-width@3.0.2: - resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - - fast-uri@3.1.2: - resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} - - fast-wrap-ansi@0.2.2: - resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} - - fast-xml-builder@1.2.0: - resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} - - fast-xml-parser@5.7.3: - resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} + fast-xml-parser@5.3.4: + resolution: {integrity: sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==} hasBin: true fastq@1.20.1: @@ -2708,8 +2977,8 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} @@ -2722,8 +2991,8 @@ packages: form-data-encoder@1.7.2: resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} - form-data@4.0.6: - resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} formdata-node@4.4.1: @@ -2738,8 +3007,8 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} - framer-motion@12.40.0: - resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==} + framer-motion@12.34.1: + resolution: {integrity: sha512-kcZyNaYQfvE2LlH6+AyOaJAQV4rGp5XbzfhsZpiSZcwDMfZUHhuxLWeyRzf5I7jip3qKRpuimPA9pXXfr111kQ==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -2756,8 +3025,8 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} - fs-extra@11.3.5: - resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} + fs-extra@11.3.3: + resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} engines: {node: '>=14.14'} fs.realpath@1.0.0: @@ -2771,8 +3040,8 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - function.prototype.name@1.2.0: - resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} engines: {node: '>= 0.4'} functions-have-names@1.2.3: @@ -2796,8 +3065,8 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.6.0: - resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + get-east-asian-width@1.4.0: + resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} engines: {node: '>=18'} get-intrinsic@1.3.0: @@ -2824,8 +3093,8 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} - get-tsconfig@4.14.0: - resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} @@ -2868,8 +3137,8 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphql@16.14.2: - resolution: {integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==} + graphql@16.12.0: + resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} gzip-size@6.0.0: @@ -2899,12 +3168,12 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} - hasown@2.0.4: - resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - headers-polyfill@5.0.1: - resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} + headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} @@ -2912,8 +3181,8 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - hono@4.12.25: - resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==} + hono@4.11.9: + resolution: {integrity: sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==} engines: {node: '>=16.9.0'} http-errors@2.0.1: @@ -2962,8 +3231,8 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} - ip-address@10.2.0: - resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + ip-address@10.0.1: + resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -2996,8 +3265,8 @@ packages: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} - is-core-module@2.16.2: - resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} is-data-view@1.0.2: @@ -3013,10 +3282,6 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true - is-document.all@1.0.0: - resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} - engines: {node: '>= 0.4'} - is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -3037,8 +3302,8 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - is-immutable-type@5.0.4: - resolution: {integrity: sha512-tDf0vB9dJJt/1USS2nvHJb8UsIAhs+pF6z8UZLNdhrzB+PKMrdcN45je9jhuFpaJOjJpoRhueFmFrRs5g/TNMA==} + is-immutable-type@5.0.1: + resolution: {integrity: sha512-LkHEOGVZZXxGl8vDs+10k3DvP++SEoYEAJLRk6buTFi6kD7QekThV7xHS0j6gpnUCQ0zpud/gMDGiV4dQneLTg==} peerDependencies: eslint: '*' typescript: '>=4.7.4' @@ -3152,9 +3417,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isexe@3.1.5: - resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} - engines: {node: '>=18'} + isexe@3.1.4: + resolution: {integrity: sha512-jCErc4h4RnTPjFq53G4whhjAMbUAqinGrCrTT4dmMNyi4zTthK+wphqbRLJtL4BN/Mq7Zzltr0m/b1X0m7PGFQ==} + engines: {node: '>=20'} iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} @@ -3164,18 +3429,18 @@ packages: resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} engines: {node: 20 || >=22} - jiti@2.7.0: - resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true - jose@6.2.3: - resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.2.0: - resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true jsesc@3.1.0: @@ -3210,8 +3475,8 @@ packages: engines: {node: '>=6'} hasBin: true - jsonfile@6.2.1: - resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} @@ -3239,74 +3504,74 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + lightningcss-android-arm64@1.30.2: + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + lightningcss-darwin-arm64@1.30.2: + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + lightningcss-darwin-x64@1.30.2: + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + lightningcss-freebsd-x64@1.30.2: + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + lightningcss-linux-arm-gnueabihf@1.30.2: + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + lightningcss-linux-arm64-gnu@1.30.2: + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + lightningcss-linux-arm64-musl@1.30.2: + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + lightningcss-linux-x64-gnu@1.30.2: + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + lightningcss-linux-x64-musl@1.30.2: + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + lightningcss-win32-arm64-msvc@1.30.2: + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + lightningcss-win32-x64-msvc@1.30.2: + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + lightningcss@1.30.2: + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} engines: {node: '>= 12.0.0'} lines-and-columns@1.2.4: @@ -3334,8 +3599,8 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.5.1: - resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + lru-cache@11.2.6: + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} engines: {node: 20 || >=22} lru-cache@5.1.1: @@ -3396,20 +3661,28 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} - miniflare@4.20260611.0: - resolution: {integrity: sha512-i+JwEo8vN96naz1WL3ntFgFyRluBDYL408zwhHKvR2jefJ464KsZ/gCmJAQ5k+oaWeb5Ug+s7yne5AyiAEswjg==} - engines: {node: '>=22.0.0'} + miniflare@4.20260217.0: + resolution: {integrity: sha512-t2v02Vi9SUiiXoHoxLvsntli7N35e/35PuRAYEqHWtHOdDX3bqQ73dBQ0tI12/8ThCb2by2tVs7qOvgwn6xSBQ==} + engines: {node: '>=18.0.0'} hasBin: true - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} - engines: {node: 18 || 20 || >=22} + minimatch@10.1.2: + resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==} + engines: {node: 20 || >=22} - minimatch@3.1.5: - resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@10.2.1: + resolution: {integrity: sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==} + engines: {node: 20 || >=22} - minimatch@8.0.7: - resolution: {integrity: sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==} + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@8.0.4: + resolution: {integrity: sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==} + engines: {node: '>=16 || 14 >=14.17'} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} minimist@1.2.8: @@ -3419,8 +3692,8 @@ packages: resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==} engines: {node: '>=8'} - minipass@7.1.3: - resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} mkdirp@1.0.4: @@ -3431,14 +3704,14 @@ packages: mnemonist@0.38.3: resolution: {integrity: sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw==} - motion-dom@12.40.0: - resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==} + motion-dom@12.34.1: + resolution: {integrity: sha512-SC7ZC5dRcGwku2g7EsPvI4q/EzHumUbqsDNumBmZTLFg+goBO5LTJvDu9MAxx+0mtX4IA78B2be/A3aRjY0jnw==} - motion-utils@12.39.0: - resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + motion-utils@12.29.2: + resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==} - motion@12.40.0: - resolution: {integrity: sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==} + motion@12.34.1: + resolution: {integrity: sha512-N9RVNGn/NSo85OgHX1wGaUWHvReuQ7dZUwuQRhHyzY2wfVOvY3cEgn0Mw4NXOsXMHL/y7EYuzA+b59PYI6EejA==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -3454,8 +3727,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msw@2.14.6: - resolution: {integrity: sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg==} + msw@2.12.10: + resolution: {integrity: sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -3464,12 +3737,12 @@ packages: typescript: optional: true - mute-stream@3.0.0: - resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} - engines: {node: ^20.17.0 || >=22.9.0} + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -3495,8 +3768,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.2.9: - resolution: {integrity: sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==} + next@16.1.6: + resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -3521,10 +3794,6 @@ packages: engines: {node: '>=10.5.0'} deprecated: Use your platform's native DOMException instead - node-exports-info@1.6.0: - resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} - engines: {node: '>= 0.4'} - node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -3538,9 +3807,8 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-releases@2.0.47: - resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} - engines: {node: '>=18'} + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} @@ -3660,10 +3928,6 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-expression-matcher@1.5.0: - resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} - engines: {node: '>=14.0.0'} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -3679,15 +3943,15 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} - path-scurry@2.0.2: - resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} - engines: {node: 18 || 20 || >=22} + path-scurry@2.0.1: + resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} + engines: {node: 20 || >=22} path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} - path-to-regexp@8.4.2: - resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -3695,12 +3959,12 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} pid-port@2.0.0: @@ -3715,22 +3979,29 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss-selector-parser@7.1.4: - resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} + postcss-selector-parser@6.0.10: + resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} + engines: {node: '>=4'} + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} postcss@8.4.31: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} powershell-utils@0.1.0: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + preact@10.28.3: + resolution: {integrity: sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -3745,8 +4016,8 @@ packages: vue-tsc: optional: true - prettier-plugin-tailwindcss@0.7.4: - resolution: {integrity: sha512-UKii4RjY05SNt/WQi6/NcOn/LsT0/ILLXsxygjbRg5/YZelsSu5jTqorYHPDGq4nZy5q5hpCu+XdGZ1xaJEQgw==} + prettier-plugin-tailwindcss@0.7.2: + resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==} engines: {node: '>=20.19'} peerDependencies: '@ianvs/prettier-plugin-sort-imports': '*' @@ -3800,8 +4071,8 @@ packages: prettier-plugin-svelte: optional: true - prettier@3.8.4: - resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==} + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} engines: {node: '>=14'} hasBin: true @@ -3824,8 +4095,8 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.15.2: - resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + qs@6.14.1: + resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} queue-microtask@1.2.3: @@ -3839,21 +4110,28 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} - react-dom@19.2.7: - resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: - react: ^19.2.7 + react: ^19.2.4 - react-icons@5.6.0: - resolution: {integrity: sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==} + react-icons@5.5.0: + resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==} peerDependencies: react: '*' react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - react@19.2.7: - resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + react-scan@0.5.3: + resolution: {integrity: sha512-qde9PupmUf0L3MU1H6bjmoukZNbCXdMyTEwP4Gh8RQ4rZPd2GGNBgEKWszwLm96E8k+sGtMpc0B9P0KyFDP6Bw==} + hasBin: true + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} recast@0.23.11: @@ -3876,8 +4154,8 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - reselect@5.2.0: - resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} @@ -3886,17 +4164,21 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - resolve@2.0.0-next.7: - resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} hasBin: true + resolve@2.0.0-next.5: + resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} + hasBin: true + restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} - rettime@0.11.11: - resolution: {integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==} + rettime@0.10.1: + resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} @@ -3913,8 +4195,8 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - safe-array-concat@1.1.4: - resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} + safe-array-concat@1.1.3: + resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} safe-push-apply@1.0.0: @@ -3935,8 +4217,8 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.8.4: - resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true @@ -3948,9 +4230,6 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} - set-cookie-parser@3.1.0: - resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} - set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -3982,8 +4261,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - side-channel-list@1.0.1: - resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} side-channel-map@1.0.1: @@ -3994,8 +4273,8 @@ packages: resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} engines: {node: '>= 0.4'} - side-channel@1.1.1: - resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} signal-exit@3.0.7: @@ -4065,12 +4344,12 @@ packages: string.prototype.repeat@1.0.0: resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} - string.prototype.trim@1.2.11: - resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} engines: {node: '>= 0.4'} - string.prototype.trimend@1.0.10: - resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} engines: {node: '>= 0.4'} string.prototype.trimstart@1.0.8: @@ -4085,8 +4364,8 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-ansi@7.2.0: - resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} strip-bom@3.0.0: @@ -4105,8 +4384,8 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - strnum@2.4.0: - resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==} + strnum@2.1.2: + resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} @@ -4133,18 +4412,21 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} - tailwind-merge@3.6.0: - resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + tailwind-merge@3.4.1: + resolution: {integrity: sha512-2OA0rFqWOkITEAOFWSBSApYkDeH9t2B3XSJuI4YztKBzK3mX0737A2qtxDZ7xkw9Zfh0bWl+r34sF3HXV+Ig7Q==} - tailwindcss@4.3.1: - resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==} + tailwindcss@4.1.18: + resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} - tapable@2.3.3: - resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} terser@5.16.9: @@ -4155,19 +4437,19 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} - tinyexec@1.2.4: - resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} - tinyglobby@0.2.17: - resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} - tldts-core@7.4.2: - resolution: {integrity: sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==} + tldts-core@7.0.23: + resolution: {integrity: sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==} - tldts@7.4.2: - resolution: {integrity: sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==} + tldts@7.0.23: + resolution: {integrity: sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==} hasBin: true to-regex-range@5.0.1: @@ -4178,15 +4460,15 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} - tough-cookie@6.0.1: - resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - ts-api-utils@2.5.0: - resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -4222,13 +4504,13 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-fest@5.7.0: - resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} + type-fest@5.4.4: + resolution: {integrity: sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==} engines: {node: '>=20'} - type-is@2.1.0: - resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} - engines: {node: '>= 18'} + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} @@ -4242,16 +4524,16 @@ packages: resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} engines: {node: '>= 0.4'} - typed-array-length@1.0.8: - resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} - typescript-eslint@8.61.0: - resolution: {integrity: sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==} + typescript-eslint@8.56.0: + resolution: {integrity: sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' + typescript: '>=4.8.4 <6.0.0' typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} @@ -4265,15 +4547,18 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - undici-types@7.24.6: - resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} undici@7.16.0: resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} engines: {node: '>=20.18.1'} - undici@7.24.8: - resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} + undici@7.18.2: + resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==} engines: {node: '>=20.18.1'} unenv@2.0.0-rc.24: @@ -4291,8 +4576,12 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - unrs-resolver@1.12.2: - resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} + unplugin@2.1.0: + resolution: {integrity: sha512-us4j03/499KhbGP8BU7Hrzrgseo+KdfJYWcbcajCOqsAyb8Gk0Yn2kiUIcZISYCb1JFaZfIuG3b42HmguVOKCQ==} + engines: {node: '>=18.12.0'} + + unrs-resolver@1.11.1: + resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} until-async@3.0.2: resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} @@ -4336,6 +4625,9 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -4351,8 +4643,8 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} - which-typed-array@1.1.22: - resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} engines: {node: '>= 0.4'} which@2.0.2: @@ -4369,21 +4661,25 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - workerd@1.20260611.1: - resolution: {integrity: sha512-CS/640T7pIJ2HYX6x2DwKFGbcSckAWN3tgcdq+ptB6SaqjWUhlzIgA/YhPuwIU+/NnMnGpqOFX/hC18Oyge63w==} + workerd@1.20260217.0: + resolution: {integrity: sha512-6jVisS6wB6KbF+F9DVoDUy9p7MON8qZCFSaL8OcDUioMwknsUPFojUISu3/c30ZOZ24D4h7oqaahFc5C6huilw==} engines: {node: '>=16'} hasBin: true - wrangler@4.100.0: - resolution: {integrity: sha512-dSQO7DO+mD6XDzkVWIWBoGLO3yw+lacWSc/KhFvd7pgfpth+kX98qb5SGRHZN8ACCDhhfwzDLXwB6qHsIHhfBg==} - engines: {node: '>=22.0.0'} + wrangler@4.66.0: + resolution: {integrity: sha512-b9RVIdKai0BXDuYg0iN0zwVnVbULkvdKGP7Bf1uFY2GhJ/nzDGqgwQbCwgDIOhmaBC8ynhk/p22M2jc8tJy+dQ==} + engines: {node: '>=20.0.0'} hasBin: true peerDependencies: - '@cloudflare/workers-types': ^4.20260611.1 + '@cloudflare/workers-types': ^4.20260217.0 peerDependenciesMeta: '@cloudflare/workers-types': optional: true + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -4395,8 +4691,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.20.1: - resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -4411,10 +4707,6 @@ packages: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} - xml-naming@0.1.0: - resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} - engines: {node: '>=16.0.0'} - y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -4422,8 +4714,8 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yaml@2.9.0: - resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} engines: {node: '>= 14.6'} hasBin: true @@ -4447,9 +4739,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - yocto-spinner@1.2.0: - resolution: {integrity: sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw==} - engines: {node: '>=18.19'} + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} yoctocolors@2.1.2: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} @@ -4461,10 +4753,10 @@ packages: youch@4.1.0-beta.10: resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} - zod-to-json-schema@3.25.2: - resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} peerDependencies: - zod: ^3.25.28 || ^4 + zod: ^3.25 || ^4 zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} @@ -4475,8 +4767,8 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} snapshots: @@ -4484,10 +4776,10 @@ snapshots: '@antfu/ni@25.0.0': dependencies: - ansis: 4.3.1 + ansis: 4.2.0 fzf: 0.5.2 package-manager-detector: 1.6.0 - tinyexec: 1.2.4 + tinyexec: 1.0.2 '@ast-grep/napi-darwin-arm64@0.40.5': optional: true @@ -4531,21 +4823,21 @@ snapshots: '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.12 + '@aws-sdk/types': 3.973.1 tslib: 2.8.1 '@aws-crypto/crc32c@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.12 + '@aws-sdk/types': 3.973.1 tslib: 2.8.1 '@aws-crypto/sha1-browser@5.2.0': dependencies: '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.12 - '@aws-sdk/util-locate-window': 3.965.7 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-locate-window': 3.965.4 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -4554,15 +4846,15 @@ snapshots: '@aws-crypto/sha256-js': 5.2.0 '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.12 - '@aws-sdk/util-locate-window': 3.965.7 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-locate-window': 3.965.4 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.12 + '@aws-sdk/types': 3.973.1 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -4571,532 +4863,695 @@ snapshots: '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.973.12 + '@aws-sdk/types': 3.973.1 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-sdk/checksums@3.1000.5': - dependencies: - '@aws-crypto/crc32': 5.2.0 - '@aws-crypto/crc32c': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/core': 3.974.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 - tslib: 2.8.1 - '@aws-sdk/client-cloudfront@3.984.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.20 - '@aws-sdk/credential-provider-node': 3.972.55 - '@aws-sdk/middleware-host-header': 3.972.21 - '@aws-sdk/middleware-logger': 3.972.20 - '@aws-sdk/middleware-recursion-detection': 3.972.22 - '@aws-sdk/middleware-user-agent': 3.972.50 - '@aws-sdk/region-config-resolver': 3.972.24 - '@aws-sdk/types': 3.973.12 + '@aws-sdk/core': 3.973.10 + '@aws-sdk/credential-provider-node': 3.972.9 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.10 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/types': 3.973.1 '@aws-sdk/util-endpoints': 3.984.0 - '@aws-sdk/util-user-agent-browser': 3.972.21 - '@aws-sdk/util-user-agent-node': 3.973.36 - '@smithy/config-resolver': 4.5.7 - '@smithy/core': 3.24.7 - '@smithy/fetch-http-handler': 5.4.7 - '@smithy/hash-node': 4.3.7 - '@smithy/invalid-dependency': 4.3.7 - '@smithy/middleware-content-length': 4.3.7 - '@smithy/middleware-endpoint': 4.5.7 - '@smithy/middleware-retry': 4.6.7 - '@smithy/middleware-serde': 4.3.7 - '@smithy/middleware-stack': 4.3.7 - '@smithy/node-config-provider': 4.4.7 - '@smithy/node-http-handler': 4.7.8 - '@smithy/protocol-http': 5.4.7 - '@smithy/smithy-client': 4.13.7 - '@smithy/types': 4.14.4 - '@smithy/url-parser': 4.3.7 - '@smithy/util-base64': 4.4.7 - '@smithy/util-body-length-browser': 4.3.7 - '@smithy/util-body-length-node': 4.3.7 - '@smithy/util-defaults-mode-browser': 4.4.7 - '@smithy/util-defaults-mode-node': 4.3.7 - '@smithy/util-endpoints': 3.5.7 - '@smithy/util-middleware': 4.3.7 - '@smithy/util-retry': 4.4.7 - '@smithy/util-stream': 4.6.7 - '@smithy/util-utf8': 4.3.7 - '@smithy/util-waiter': 4.4.7 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.8 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.23.0 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-retry': 4.4.31 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.10 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.30 + '@smithy/util-defaults-mode-node': 4.2.33 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-stream': 4.5.12 + '@smithy/util-utf8': 4.2.0 + '@smithy/util-waiter': 4.2.8 tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt '@aws-sdk/client-dynamodb@3.984.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.20 - '@aws-sdk/credential-provider-node': 3.972.55 - '@aws-sdk/dynamodb-codec': 3.973.20 - '@aws-sdk/middleware-endpoint-discovery': 3.972.18 - '@aws-sdk/middleware-host-header': 3.972.21 - '@aws-sdk/middleware-logger': 3.972.20 - '@aws-sdk/middleware-recursion-detection': 3.972.22 - '@aws-sdk/middleware-user-agent': 3.972.50 - '@aws-sdk/region-config-resolver': 3.972.24 - '@aws-sdk/types': 3.973.12 + '@aws-sdk/core': 3.973.10 + '@aws-sdk/credential-provider-node': 3.972.9 + '@aws-sdk/dynamodb-codec': 3.972.11 + '@aws-sdk/middleware-endpoint-discovery': 3.972.3 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.10 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/types': 3.973.1 '@aws-sdk/util-endpoints': 3.984.0 - '@aws-sdk/util-user-agent-browser': 3.972.21 - '@aws-sdk/util-user-agent-node': 3.973.36 - '@smithy/config-resolver': 4.5.7 - '@smithy/core': 3.24.7 - '@smithy/fetch-http-handler': 5.4.7 - '@smithy/hash-node': 4.3.7 - '@smithy/invalid-dependency': 4.3.7 - '@smithy/middleware-content-length': 4.3.7 - '@smithy/middleware-endpoint': 4.5.7 - '@smithy/middleware-retry': 4.6.7 - '@smithy/middleware-serde': 4.3.7 - '@smithy/middleware-stack': 4.3.7 - '@smithy/node-config-provider': 4.4.7 - '@smithy/node-http-handler': 4.7.8 - '@smithy/protocol-http': 5.4.7 - '@smithy/smithy-client': 4.13.7 - '@smithy/types': 4.14.4 - '@smithy/url-parser': 4.3.7 - '@smithy/util-base64': 4.4.7 - '@smithy/util-body-length-browser': 4.3.7 - '@smithy/util-body-length-node': 4.3.7 - '@smithy/util-defaults-mode-browser': 4.4.7 - '@smithy/util-defaults-mode-node': 4.3.7 - '@smithy/util-endpoints': 3.5.7 - '@smithy/util-middleware': 4.3.7 - '@smithy/util-retry': 4.4.7 - '@smithy/util-utf8': 4.3.7 - '@smithy/util-waiter': 4.4.7 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.8 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.23.0 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-retry': 4.4.31 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.10 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.30 + '@smithy/util-defaults-mode-node': 4.2.33 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-utf8': 4.2.0 + '@smithy/util-waiter': 4.2.8 tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt '@aws-sdk/client-lambda@3.984.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.20 - '@aws-sdk/credential-provider-node': 3.972.55 - '@aws-sdk/middleware-host-header': 3.972.21 - '@aws-sdk/middleware-logger': 3.972.20 - '@aws-sdk/middleware-recursion-detection': 3.972.22 - '@aws-sdk/middleware-user-agent': 3.972.50 - '@aws-sdk/region-config-resolver': 3.972.24 - '@aws-sdk/types': 3.973.12 + '@aws-sdk/core': 3.973.10 + '@aws-sdk/credential-provider-node': 3.972.9 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.10 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/types': 3.973.1 '@aws-sdk/util-endpoints': 3.984.0 - '@aws-sdk/util-user-agent-browser': 3.972.21 - '@aws-sdk/util-user-agent-node': 3.973.36 - '@smithy/config-resolver': 4.5.7 - '@smithy/core': 3.24.7 - '@smithy/eventstream-serde-browser': 4.3.7 - '@smithy/eventstream-serde-config-resolver': 4.4.7 - '@smithy/eventstream-serde-node': 4.3.7 - '@smithy/fetch-http-handler': 5.4.7 - '@smithy/hash-node': 4.3.7 - '@smithy/invalid-dependency': 4.3.7 - '@smithy/middleware-content-length': 4.3.7 - '@smithy/middleware-endpoint': 4.5.7 - '@smithy/middleware-retry': 4.6.7 - '@smithy/middleware-serde': 4.3.7 - '@smithy/middleware-stack': 4.3.7 - '@smithy/node-config-provider': 4.4.7 - '@smithy/node-http-handler': 4.7.8 - '@smithy/protocol-http': 5.4.7 - '@smithy/smithy-client': 4.13.7 - '@smithy/types': 4.14.4 - '@smithy/url-parser': 4.3.7 - '@smithy/util-base64': 4.4.7 - '@smithy/util-body-length-browser': 4.3.7 - '@smithy/util-body-length-node': 4.3.7 - '@smithy/util-defaults-mode-browser': 4.4.7 - '@smithy/util-defaults-mode-node': 4.3.7 - '@smithy/util-endpoints': 3.5.7 - '@smithy/util-middleware': 4.3.7 - '@smithy/util-retry': 4.4.7 - '@smithy/util-stream': 4.6.7 - '@smithy/util-utf8': 4.3.7 - '@smithy/util-waiter': 4.4.7 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.8 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.23.0 + '@smithy/eventstream-serde-browser': 4.2.8 + '@smithy/eventstream-serde-config-resolver': 4.3.8 + '@smithy/eventstream-serde-node': 4.2.8 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-retry': 4.4.31 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.10 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.30 + '@smithy/util-defaults-mode-node': 4.2.33 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-stream': 4.5.12 + '@smithy/util-utf8': 4.2.0 + '@smithy/util-waiter': 4.2.8 tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt '@aws-sdk/client-s3@3.984.0': dependencies: '@aws-crypto/sha1-browser': 5.2.0 '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.20 - '@aws-sdk/credential-provider-node': 3.972.55 - '@aws-sdk/middleware-bucket-endpoint': 3.972.24 - '@aws-sdk/middleware-expect-continue': 3.972.20 - '@aws-sdk/middleware-flexible-checksums': 3.974.30 - '@aws-sdk/middleware-host-header': 3.972.21 - '@aws-sdk/middleware-location-constraint': 3.972.17 - '@aws-sdk/middleware-logger': 3.972.20 - '@aws-sdk/middleware-recursion-detection': 3.972.22 - '@aws-sdk/middleware-sdk-s3': 3.972.51 - '@aws-sdk/middleware-ssec': 3.972.17 - '@aws-sdk/middleware-user-agent': 3.972.50 - '@aws-sdk/region-config-resolver': 3.972.24 + '@aws-sdk/core': 3.973.10 + '@aws-sdk/credential-provider-node': 3.972.9 + '@aws-sdk/middleware-bucket-endpoint': 3.972.3 + '@aws-sdk/middleware-expect-continue': 3.972.3 + '@aws-sdk/middleware-flexible-checksums': 3.972.8 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-location-constraint': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-sdk-s3': 3.972.10 + '@aws-sdk/middleware-ssec': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.10 + '@aws-sdk/region-config-resolver': 3.972.3 '@aws-sdk/signature-v4-multi-region': 3.984.0 - '@aws-sdk/types': 3.973.12 + '@aws-sdk/types': 3.973.1 '@aws-sdk/util-endpoints': 3.984.0 - '@aws-sdk/util-user-agent-browser': 3.972.21 - '@aws-sdk/util-user-agent-node': 3.973.36 - '@smithy/config-resolver': 4.5.7 - '@smithy/core': 3.24.7 - '@smithy/eventstream-serde-browser': 4.3.7 - '@smithy/eventstream-serde-config-resolver': 4.4.7 - '@smithy/eventstream-serde-node': 4.3.7 - '@smithy/fetch-http-handler': 5.4.7 - '@smithy/hash-blob-browser': 4.3.7 - '@smithy/hash-node': 4.3.7 - '@smithy/hash-stream-node': 4.3.7 - '@smithy/invalid-dependency': 4.3.7 - '@smithy/md5-js': 4.3.7 - '@smithy/middleware-content-length': 4.3.7 - '@smithy/middleware-endpoint': 4.5.7 - '@smithy/middleware-retry': 4.6.7 - '@smithy/middleware-serde': 4.3.7 - '@smithy/middleware-stack': 4.3.7 - '@smithy/node-config-provider': 4.4.7 - '@smithy/node-http-handler': 4.7.8 - '@smithy/protocol-http': 5.4.7 - '@smithy/smithy-client': 4.13.7 - '@smithy/types': 4.14.4 - '@smithy/url-parser': 4.3.7 - '@smithy/util-base64': 4.4.7 - '@smithy/util-body-length-browser': 4.3.7 - '@smithy/util-body-length-node': 4.3.7 - '@smithy/util-defaults-mode-browser': 4.4.7 - '@smithy/util-defaults-mode-node': 4.3.7 - '@smithy/util-endpoints': 3.5.7 - '@smithy/util-middleware': 4.3.7 - '@smithy/util-retry': 4.4.7 - '@smithy/util-stream': 4.6.7 - '@smithy/util-utf8': 4.3.7 - '@smithy/util-waiter': 4.4.7 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.8 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.23.0 + '@smithy/eventstream-serde-browser': 4.2.8 + '@smithy/eventstream-serde-config-resolver': 4.3.8 + '@smithy/eventstream-serde-node': 4.2.8 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-blob-browser': 4.2.9 + '@smithy/hash-node': 4.2.8 + '@smithy/hash-stream-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/md5-js': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-retry': 4.4.31 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.10 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.30 + '@smithy/util-defaults-mode-node': 4.2.33 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-stream': 4.5.12 + '@smithy/util-utf8': 4.2.0 + '@smithy/util-waiter': 4.2.8 tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt '@aws-sdk/client-sqs@3.984.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.20 - '@aws-sdk/credential-provider-node': 3.972.55 - '@aws-sdk/middleware-host-header': 3.972.21 - '@aws-sdk/middleware-logger': 3.972.20 - '@aws-sdk/middleware-recursion-detection': 3.972.22 - '@aws-sdk/middleware-sdk-sqs': 3.972.30 - '@aws-sdk/middleware-user-agent': 3.972.50 - '@aws-sdk/region-config-resolver': 3.972.24 - '@aws-sdk/types': 3.973.12 + '@aws-sdk/core': 3.973.10 + '@aws-sdk/credential-provider-node': 3.972.9 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-sdk-sqs': 3.972.7 + '@aws-sdk/middleware-user-agent': 3.972.10 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/types': 3.973.1 '@aws-sdk/util-endpoints': 3.984.0 - '@aws-sdk/util-user-agent-browser': 3.972.21 - '@aws-sdk/util-user-agent-node': 3.973.36 - '@smithy/config-resolver': 4.5.7 - '@smithy/core': 3.24.7 - '@smithy/fetch-http-handler': 5.4.7 - '@smithy/hash-node': 4.3.7 - '@smithy/invalid-dependency': 4.3.7 - '@smithy/md5-js': 4.3.7 - '@smithy/middleware-content-length': 4.3.7 - '@smithy/middleware-endpoint': 4.5.7 - '@smithy/middleware-retry': 4.6.7 - '@smithy/middleware-serde': 4.3.7 - '@smithy/middleware-stack': 4.3.7 - '@smithy/node-config-provider': 4.4.7 - '@smithy/node-http-handler': 4.7.8 - '@smithy/protocol-http': 5.4.7 - '@smithy/smithy-client': 4.13.7 - '@smithy/types': 4.14.4 - '@smithy/url-parser': 4.3.7 - '@smithy/util-base64': 4.4.7 - '@smithy/util-body-length-browser': 4.3.7 - '@smithy/util-body-length-node': 4.3.7 - '@smithy/util-defaults-mode-browser': 4.4.7 - '@smithy/util-defaults-mode-node': 4.3.7 - '@smithy/util-endpoints': 3.5.7 - '@smithy/util-middleware': 4.3.7 - '@smithy/util-retry': 4.4.7 - '@smithy/util-utf8': 4.3.7 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.8 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.23.0 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/md5-js': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-retry': 4.4.31 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.10 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.30 + '@smithy/util-defaults-mode-node': 4.2.33 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt - '@aws-sdk/core@3.974.20': + '@aws-sdk/client-sso@3.990.0': dependencies: - '@aws-sdk/types': 3.973.12 - '@aws-sdk/xml-builder': 3.972.29 - '@aws/lambda-invoke-store': 0.2.4 - '@smithy/core': 3.24.7 - '@smithy/signature-v4': 5.4.7 - '@smithy/types': 4.14.4 - bowser: 2.14.1 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.10 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.10 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.990.0 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.8 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.23.0 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-retry': 4.4.31 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.10 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.30 + '@smithy/util-defaults-mode-node': 4.2.33 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt - '@aws-sdk/credential-provider-env@3.972.46': + '@aws-sdk/core@3.973.10': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/xml-builder': 3.972.4 + '@smithy/core': 3.23.0 + '@smithy/node-config-provider': 4.3.8 + '@smithy/property-provider': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/signature-v4': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.48': + '@aws-sdk/crc64-nvme@3.972.0': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/fetch-http-handler': 5.4.7 - '@smithy/node-http-handler': 4.7.8 - '@smithy/types': 4.14.4 + '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.972.53': + '@aws-sdk/credential-provider-env@3.972.8': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/credential-provider-env': 3.972.46 - '@aws-sdk/credential-provider-http': 3.972.48 - '@aws-sdk/credential-provider-login': 3.972.52 - '@aws-sdk/credential-provider-process': 3.972.46 - '@aws-sdk/credential-provider-sso': 3.972.52 - '@aws-sdk/credential-provider-web-identity': 3.972.52 - '@aws-sdk/nested-clients': 3.997.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/credential-provider-imds': 4.3.9 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.973.10 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-login@3.972.52': + '@aws-sdk/credential-provider-http@3.972.10': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/nested-clients': 3.997.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.973.10 + '@aws-sdk/types': 3.973.1 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/node-http-handler': 4.4.10 + '@smithy/property-provider': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/util-stream': 4.5.12 tslib: 2.8.1 - '@aws-sdk/credential-provider-node@3.972.55': + '@aws-sdk/credential-provider-ini@3.972.8': dependencies: - '@aws-sdk/credential-provider-env': 3.972.46 - '@aws-sdk/credential-provider-http': 3.972.48 - '@aws-sdk/credential-provider-ini': 3.972.53 - '@aws-sdk/credential-provider-process': 3.972.46 - '@aws-sdk/credential-provider-sso': 3.972.52 - '@aws-sdk/credential-provider-web-identity': 3.972.52 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/credential-provider-imds': 4.3.9 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.973.10 + '@aws-sdk/credential-provider-env': 3.972.8 + '@aws-sdk/credential-provider-http': 3.972.10 + '@aws-sdk/credential-provider-login': 3.972.8 + '@aws-sdk/credential-provider-process': 3.972.8 + '@aws-sdk/credential-provider-sso': 3.972.8 + '@aws-sdk/credential-provider-web-identity': 3.972.8 + '@aws-sdk/nested-clients': 3.990.0 + '@aws-sdk/types': 3.973.1 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt - '@aws-sdk/credential-provider-process@3.972.46': + '@aws-sdk/credential-provider-login@3.972.8': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.973.10 + '@aws-sdk/nested-clients': 3.990.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt - '@aws-sdk/credential-provider-sso@3.972.52': + '@aws-sdk/credential-provider-node@3.972.9': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/nested-clients': 3.997.20 - '@aws-sdk/token-providers': 3.1066.0 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@aws-sdk/credential-provider-env': 3.972.8 + '@aws-sdk/credential-provider-http': 3.972.10 + '@aws-sdk/credential-provider-ini': 3.972.8 + '@aws-sdk/credential-provider-process': 3.972.8 + '@aws-sdk/credential-provider-sso': 3.972.8 + '@aws-sdk/credential-provider-web-identity': 3.972.8 + '@aws-sdk/types': 3.973.1 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt - '@aws-sdk/credential-provider-web-identity@3.972.52': + '@aws-sdk/credential-provider-process@3.972.8': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/nested-clients': 3.997.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.973.10 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/dynamodb-codec@3.973.20': + '@aws-sdk/credential-provider-sso@3.972.8': dependencies: - '@aws-sdk/core': 3.974.20 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@aws-sdk/client-sso': 3.990.0 + '@aws-sdk/core': 3.973.10 + '@aws-sdk/token-providers': 3.990.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.972.8': + dependencies: + '@aws-sdk/core': 3.973.10 + '@aws-sdk/nested-clients': 3.990.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/dynamodb-codec@3.972.11': + dependencies: + '@aws-sdk/core': 3.973.10 + '@smithy/core': 3.23.0 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 tslib: 2.8.1 - '@aws-sdk/endpoint-cache@3.972.7': + '@aws-sdk/endpoint-cache@3.972.2': dependencies: mnemonist: 0.38.3 tslib: 2.8.1 - '@aws-sdk/middleware-bucket-endpoint@3.972.24': + '@aws-sdk/middleware-bucket-endpoint@3.972.3': dependencies: - '@aws-sdk/middleware-sdk-s3': 3.972.51 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-arn-parser': 3.972.2 + '@smithy/node-config-provider': 4.3.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-config-provider': 4.2.0 tslib: 2.8.1 - '@aws-sdk/middleware-endpoint-discovery@3.972.18': + '@aws-sdk/middleware-endpoint-discovery@3.972.3': dependencies: - '@aws-sdk/endpoint-cache': 3.972.7 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@aws-sdk/endpoint-cache': 3.972.2 + '@aws-sdk/types': 3.973.1 + '@smithy/node-config-provider': 4.3.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/middleware-expect-continue@3.972.20': + '@aws-sdk/middleware-expect-continue@3.972.3': dependencies: - '@aws-sdk/middleware-sdk-s3': 3.972.51 + '@aws-sdk/types': 3.973.1 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/middleware-flexible-checksums@3.974.30': + '@aws-sdk/middleware-flexible-checksums@3.972.8': dependencies: - '@aws-sdk/checksums': 3.1000.5 + '@aws-crypto/crc32': 5.2.0 + '@aws-crypto/crc32c': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/core': 3.973.10 + '@aws-sdk/crc64-nvme': 3.972.0 + '@aws-sdk/types': 3.973.1 + '@smithy/is-array-buffer': 4.2.0 + '@smithy/node-config-provider': 4.3.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-stream': 4.5.12 + '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 - '@aws-sdk/middleware-host-header@3.972.21': + '@aws-sdk/middleware-host-header@3.972.3': dependencies: - '@aws-sdk/core': 3.974.20 + '@aws-sdk/types': 3.973.1 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/middleware-location-constraint@3.972.17': + '@aws-sdk/middleware-location-constraint@3.972.3': dependencies: - '@aws-sdk/middleware-sdk-s3': 3.972.51 + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/middleware-logger@3.972.20': + '@aws-sdk/middleware-logger@3.972.3': dependencies: - '@aws-sdk/core': 3.974.20 + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/middleware-recursion-detection@3.972.22': + '@aws-sdk/middleware-recursion-detection@3.972.3': dependencies: - '@aws-sdk/core': 3.974.20 + '@aws-sdk/types': 3.973.1 + '@aws/lambda-invoke-store': 0.2.3 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.972.51': + '@aws-sdk/middleware-sdk-s3@3.972.10': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/signature-v4-multi-region': 3.996.34 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.973.10 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-arn-parser': 3.972.2 + '@smithy/core': 3.23.0 + '@smithy/node-config-provider': 4.3.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/signature-v4': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-stream': 4.5.12 + '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-sqs@3.972.30': + '@aws-sdk/middleware-sdk-sqs@3.972.7': dependencies: - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@aws-sdk/types': 3.973.1 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 - '@aws-sdk/middleware-ssec@3.972.17': + '@aws-sdk/middleware-ssec@3.972.3': dependencies: - '@aws-sdk/middleware-sdk-s3': 3.972.51 + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/middleware-user-agent@3.972.50': + '@aws-sdk/middleware-user-agent@3.972.10': dependencies: - '@aws-sdk/core': 3.974.20 + '@aws-sdk/core': 3.973.10 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.990.0 + '@smithy/core': 3.23.0 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.997.20': + '@aws-sdk/nested-clients@3.990.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.20 - '@aws-sdk/signature-v4-multi-region': 3.996.34 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/fetch-http-handler': 5.4.7 - '@smithy/node-http-handler': 4.7.8 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.973.10 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.10 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.990.0 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.8 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.23.0 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-retry': 4.4.31 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.10 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.30 + '@smithy/util-defaults-mode-node': 4.2.33 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt - '@aws-sdk/region-config-resolver@3.972.24': + '@aws-sdk/region-config-resolver@3.972.3': dependencies: - '@aws-sdk/core': 3.974.20 + '@aws-sdk/types': 3.973.1 + '@smithy/config-resolver': 4.4.6 + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 tslib: 2.8.1 '@aws-sdk/signature-v4-multi-region@3.984.0': dependencies: - '@aws-sdk/middleware-sdk-s3': 3.972.51 - '@aws-sdk/types': 3.973.12 - '@smithy/protocol-http': 5.4.7 - '@smithy/signature-v4': 5.4.7 - '@smithy/types': 4.14.4 + '@aws-sdk/middleware-sdk-s3': 3.972.10 + '@aws-sdk/types': 3.973.1 + '@smithy/protocol-http': 5.3.8 + '@smithy/signature-v4': 5.3.8 + '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.996.34': + '@aws-sdk/token-providers@3.990.0': dependencies: - '@aws-sdk/types': 3.973.12 - '@smithy/signature-v4': 5.4.7 - '@smithy/types': 4.14.4 + '@aws-sdk/core': 3.973.10 + '@aws-sdk/nested-clients': 3.990.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/types@3.973.1': + dependencies: + '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1066.0': + '@aws-sdk/util-arn-parser@3.972.2': dependencies: - '@aws-sdk/core': 3.974.20 - '@aws-sdk/nested-clients': 3.997.20 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 - tslib: 2.8.1 - - '@aws-sdk/types@3.973.12': - dependencies: - '@smithy/types': 4.14.4 tslib: 2.8.1 '@aws-sdk/util-endpoints@3.984.0': dependencies: - '@aws-sdk/types': 3.973.12 - '@smithy/types': 4.14.4 - '@smithy/url-parser': 4.3.7 - '@smithy/util-endpoints': 3.5.7 + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-endpoints': 3.2.8 tslib: 2.8.1 - '@aws-sdk/util-locate-window@3.965.7': + '@aws-sdk/util-endpoints@3.990.0': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-endpoints': 3.2.8 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.4': dependencies: tslib: 2.8.1 - '@aws-sdk/util-user-agent-browser@3.972.21': + '@aws-sdk/util-user-agent-browser@3.972.3': dependencies: - '@aws-sdk/core': 3.974.20 + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/util-user-agent-node@3.973.36': + '@aws-sdk/util-user-agent-node@3.972.8': dependencies: - '@aws-sdk/core': 3.974.20 + '@aws-sdk/middleware-user-agent': 3.972.10 + '@aws-sdk/types': 3.973.1 + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.29': + '@aws-sdk/xml-builder@3.972.4': dependencies: - '@smithy/types': 4.14.4 - fast-xml-parser: 5.7.3 + '@smithy/types': 4.12.0 + fast-xml-parser: 5.3.4 tslib: 2.8.1 - '@aws/lambda-invoke-store@0.2.4': {} + '@aws/lambda-invoke-store@0.2.3': {} - '@babel/code-frame@7.29.7': + '@babel/code-frame@7.29.0': dependencies: - '@babel/helper-validator-identifier': 7.29.7 + '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.29.7': {} + '@babel/compat-data@7.29.0': {} - '@babel/core@7.29.7': + '@babel/core@7.29.0': dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) - '@babel/helpers': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -5106,210 +5561,211 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.29.7': + '@babel/generator@7.29.1': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-annotate-as-pure@7.29.7': + '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.0 - '@babel/helper-compilation-targets@7.29.7': + '@babel/helper-compilation-targets@7.28.6': dependencies: - '@babel/compat-data': 7.29.7 - '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.2 + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7 - '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/helper-globals@7.29.7': {} + '@babel/helper-globals@7.28.0': {} - '@babel/helper-member-expression-to-functions@7.29.7': + '@babel/helper-member-expression-to-functions@7.28.5': dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.29.7': + '@babel/helper-module-imports@7.28.6': dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-optimise-call-expression@7.29.7': + '@babel/helper-optimise-call-expression@7.27.1': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.0 - '@babel/helper-plugin-utils@7.29.7': {} + '@babel/helper-plugin-utils@7.28.6': {} - '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7 - '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} - '@babel/helper-validator-option@7.29.7': {} + '@babel/helper-validator-option@7.27.1': {} - '@babel/helpers@7.29.7': + '@babel/helpers@7.28.6': dependencies: - '@babel/template': 7.29.7 - '@babel/types': 7.29.7 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 - '@babel/parser@7.29.7': + '@babel/parser@7.29.0': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.0 - '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.29.7 + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.29.7(@babel/core@7.29.7)': + '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-validator-option': 7.29.7 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) transitivePeerDependencies: - supports-color - '@babel/runtime@7.29.7': {} + '@babel/runtime@7.28.6': {} - '@babel/template@7.29.7': + '@babel/template@7.28.6': dependencies: - '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 - '@babel/traverse@7.29.7': + '@babel/traverse@7.29.0': dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/types': 7.29.7 + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 debug: 4.4.3 transitivePeerDependencies: - supports-color - '@babel/types@7.29.7': + '@babel/types@7.29.0': dependencies: - '@babel/helper-string-parser': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 - '@base-ui/react@1.5.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@base-ui/react@1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@babel/runtime': 7.29.7 - '@base-ui/utils': 0.2.9(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@floating-ui/utils': 0.2.11 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - use-sync-external-store: 1.6.0(react@19.2.7) + '@babel/runtime': 7.28.6 + '@base-ui/utils': 0.2.5(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/utils': 0.2.10 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + tabbable: 6.4.0 + use-sync-external-store: 1.6.0(react@19.2.4) optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.14 - '@base-ui/utils@0.2.9(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@base-ui/utils@0.2.5(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@babel/runtime': 7.29.7 - '@floating-ui/utils': 0.2.11 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - reselect: 5.2.0 - use-sync-external-store: 1.6.0(react@19.2.7) + '@babel/runtime': 7.28.6 + '@floating-ui/utils': 0.2.10 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + reselect: 5.1.1 + use-sync-external-store: 1.6.0(react@19.2.4) optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.14 - '@cloudflare/kv-asset-handler@0.5.0': {} + '@cloudflare/kv-asset-handler@0.4.2': {} - '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260611.1)': + '@cloudflare/unenv-preset@2.13.0(unenv@2.0.0-rc.24)(workerd@1.20260217.0)': dependencies: unenv: 2.0.0-rc.24 optionalDependencies: - workerd: 1.20260611.1 + workerd: 1.20260217.0 - '@cloudflare/workerd-darwin-64@1.20260611.1': + '@cloudflare/workerd-darwin-64@1.20260217.0': optional: true - '@cloudflare/workerd-darwin-arm64@1.20260611.1': + '@cloudflare/workerd-darwin-arm64@1.20260217.0': optional: true - '@cloudflare/workerd-linux-64@1.20260611.1': + '@cloudflare/workerd-linux-64@1.20260217.0': optional: true - '@cloudflare/workerd-linux-arm64@1.20260611.1': + '@cloudflare/workerd-linux-arm64@1.20260217.0': optional: true - '@cloudflare/workerd-windows-64@1.20260611.1': + '@cloudflare/workerd-windows-64@1.20260217.0': optional: true '@cspotcode/source-map-support@0.8.1': @@ -5320,51 +5776,47 @@ snapshots: dependencies: commander: 11.1.0 dotenv: 16.6.1 - eciesjs: 0.4.18 + eciesjs: 0.4.17 execa: 5.1.1 - fdir: 6.5.0(picomatch@4.0.4) + fdir: 6.5.0(picomatch@4.0.3) ignore: 5.3.2 object-treeify: 1.1.33 - picomatch: 4.0.4 + picomatch: 4.0.3 which: 4.0.0 - '@dotenvx/dotenvx@1.71.3': + '@dotenvx/dotenvx@1.52.0': dependencies: commander: 11.1.0 - dotenv: 17.4.2 - eciesjs: 0.4.18 - enquirer: 2.4.1 + dotenv: 17.3.1 + eciesjs: 0.4.17 execa: 5.1.1 - fdir: 6.5.0(picomatch@4.0.4) + fdir: 6.5.0(picomatch@4.0.3) ignore: 5.3.2 object-treeify: 1.1.33 - picomatch: 4.0.4 + picomatch: 4.0.3 which: 4.0.0 - yocto-spinner: 1.2.0 - '@ecies/ciphers@0.2.6(@noble/ciphers@1.3.0)': + '@ecies/ciphers@0.2.5(@noble/ciphers@1.3.0)': dependencies: '@noble/ciphers': 1.3.0 - '@emnapi/core@1.10.0': + '@emnapi/core@1.8.1': dependencies: - '@emnapi/wasi-threads': 1.2.1 + '@emnapi/wasi-threads': 1.1.0 tslib: 2.8.1 optional: true - '@emnapi/runtime@1.10.0': + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.11.1': + '@emnapi/wasi-threads@1.1.0': dependencies: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.2.1': - dependencies: - tslib: 2.8.1 + '@esbuild/aix-ppc64@0.25.12': optional: true '@esbuild/aix-ppc64@0.25.4': @@ -5373,182 +5825,257 @@ snapshots: '@esbuild/aix-ppc64@0.27.3': optional: true + '@esbuild/android-arm64@0.25.12': + optional: true + '@esbuild/android-arm64@0.25.4': optional: true '@esbuild/android-arm64@0.27.3': optional: true + '@esbuild/android-arm@0.25.12': + optional: true + '@esbuild/android-arm@0.25.4': optional: true '@esbuild/android-arm@0.27.3': optional: true + '@esbuild/android-x64@0.25.12': + optional: true + '@esbuild/android-x64@0.25.4': optional: true '@esbuild/android-x64@0.27.3': optional: true + '@esbuild/darwin-arm64@0.25.12': + optional: true + '@esbuild/darwin-arm64@0.25.4': optional: true '@esbuild/darwin-arm64@0.27.3': optional: true + '@esbuild/darwin-x64@0.25.12': + optional: true + '@esbuild/darwin-x64@0.25.4': optional: true '@esbuild/darwin-x64@0.27.3': optional: true + '@esbuild/freebsd-arm64@0.25.12': + optional: true + '@esbuild/freebsd-arm64@0.25.4': optional: true '@esbuild/freebsd-arm64@0.27.3': optional: true + '@esbuild/freebsd-x64@0.25.12': + optional: true + '@esbuild/freebsd-x64@0.25.4': optional: true '@esbuild/freebsd-x64@0.27.3': optional: true + '@esbuild/linux-arm64@0.25.12': + optional: true + '@esbuild/linux-arm64@0.25.4': optional: true '@esbuild/linux-arm64@0.27.3': optional: true + '@esbuild/linux-arm@0.25.12': + optional: true + '@esbuild/linux-arm@0.25.4': optional: true '@esbuild/linux-arm@0.27.3': optional: true + '@esbuild/linux-ia32@0.25.12': + optional: true + '@esbuild/linux-ia32@0.25.4': optional: true '@esbuild/linux-ia32@0.27.3': optional: true + '@esbuild/linux-loong64@0.25.12': + optional: true + '@esbuild/linux-loong64@0.25.4': optional: true '@esbuild/linux-loong64@0.27.3': optional: true + '@esbuild/linux-mips64el@0.25.12': + optional: true + '@esbuild/linux-mips64el@0.25.4': optional: true '@esbuild/linux-mips64el@0.27.3': optional: true + '@esbuild/linux-ppc64@0.25.12': + optional: true + '@esbuild/linux-ppc64@0.25.4': optional: true '@esbuild/linux-ppc64@0.27.3': optional: true + '@esbuild/linux-riscv64@0.25.12': + optional: true + '@esbuild/linux-riscv64@0.25.4': optional: true '@esbuild/linux-riscv64@0.27.3': optional: true + '@esbuild/linux-s390x@0.25.12': + optional: true + '@esbuild/linux-s390x@0.25.4': optional: true '@esbuild/linux-s390x@0.27.3': optional: true + '@esbuild/linux-x64@0.25.12': + optional: true + '@esbuild/linux-x64@0.25.4': optional: true '@esbuild/linux-x64@0.27.3': optional: true + '@esbuild/netbsd-arm64@0.25.12': + optional: true + '@esbuild/netbsd-arm64@0.25.4': optional: true '@esbuild/netbsd-arm64@0.27.3': optional: true + '@esbuild/netbsd-x64@0.25.12': + optional: true + '@esbuild/netbsd-x64@0.25.4': optional: true '@esbuild/netbsd-x64@0.27.3': optional: true + '@esbuild/openbsd-arm64@0.25.12': + optional: true + '@esbuild/openbsd-arm64@0.25.4': optional: true '@esbuild/openbsd-arm64@0.27.3': optional: true + '@esbuild/openbsd-x64@0.25.12': + optional: true + '@esbuild/openbsd-x64@0.25.4': optional: true '@esbuild/openbsd-x64@0.27.3': optional: true + '@esbuild/openharmony-arm64@0.25.12': + optional: true + '@esbuild/openharmony-arm64@0.27.3': optional: true + '@esbuild/sunos-x64@0.25.12': + optional: true + '@esbuild/sunos-x64@0.25.4': optional: true '@esbuild/sunos-x64@0.27.3': optional: true + '@esbuild/win32-arm64@0.25.12': + optional: true + '@esbuild/win32-arm64@0.25.4': optional: true '@esbuild/win32-arm64@0.27.3': optional: true + '@esbuild/win32-ia32@0.25.12': + optional: true + '@esbuild/win32-ia32@0.25.4': optional: true '@esbuild/win32-ia32@0.27.3': optional: true + '@esbuild/win32-x64@0.25.12': + optional: true + '@esbuild/win32-x64@0.25.4': optional: true '@esbuild/win32-x64@0.27.3': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.7.0))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': dependencies: - eslint: 9.39.4(jiti@2.7.0) + eslint: 9.39.2(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint-react/ast@2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': + '@eslint-react/ast@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-react/eff': 2.13.0 - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - eslint: 9.39.4(jiti@2.7.0) + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) string-ts: 2.3.1 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@eslint-react/core@2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': + '@eslint-react/core@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@eslint-react/ast': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@eslint-react/eff': 2.13.0 - '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - eslint: 9.39.4(jiti@2.7.0) + '@eslint-react/shared': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/var': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) ts-pattern: 5.9.0 typescript: 5.9.3 transitivePeerDependencies: @@ -5556,56 +6083,56 @@ snapshots: '@eslint-react/eff@2.13.0': {} - '@eslint-react/eslint-plugin@2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': + '@eslint-react/eslint-plugin@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-react/eff': 2.13.0 - '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/type-utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - eslint: 9.39.4(jiti@2.7.0) - eslint-plugin-react-dom: 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - eslint-plugin-react-hooks-extra: 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - eslint-plugin-react-naming-convention: 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - eslint-plugin-react-rsc: 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - eslint-plugin-react-web-api: 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - eslint-plugin-react-x: 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - ts-api-utils: 2.5.0(typescript@5.9.3) + '@eslint-react/shared': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/type-utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + eslint-plugin-react-dom: 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-react-hooks-extra: 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-react-naming-convention: 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-react-rsc: 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-react-web-api: 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-react-x: 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@eslint-react/shared@2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': + '@eslint-react/shared@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-react/eff': 2.13.0 - '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - eslint: 9.39.4(jiti@2.7.0) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) ts-pattern: 5.9.0 typescript: 5.9.3 - zod: 4.4.3 + zod: 4.3.6 transitivePeerDependencies: - supports-color - '@eslint-react/var@2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': + '@eslint-react/var@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@eslint-react/ast': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@eslint-react/eff': 2.13.0 - '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - eslint: 9.39.4(jiti@2.7.0) + '@eslint-react/shared': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) ts-pattern: 5.9.0 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@eslint/config-array@0.21.2': + '@eslint/config-array@0.21.1': dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3 - minimatch: 3.1.5 + minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -5617,21 +6144,21 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.5': + '@eslint/eslintrc@3.3.3': dependencies: - ajv: 6.15.0 + ajv: 6.12.6 debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.2.0 - minimatch: 3.1.5 + js-yaml: 4.1.1 + minimatch: 3.1.2 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color - '@eslint/js@9.39.4': {} + '@eslint/js@9.39.2': {} '@eslint/object-schema@2.1.7': {} @@ -5640,44 +6167,39 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@floating-ui/core@1.7.5': + '@floating-ui/core@1.7.4': dependencies: - '@floating-ui/utils': 0.2.11 + '@floating-ui/utils': 0.2.10 - '@floating-ui/dom@1.7.6': + '@floating-ui/dom@1.7.5': dependencies: - '@floating-ui/core': 1.7.5 - '@floating-ui/utils': 0.2.11 + '@floating-ui/core': 1.7.4 + '@floating-ui/utils': 0.2.10 - '@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@floating-ui/react-dom@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@floating-ui/dom': 1.7.6 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) + '@floating-ui/dom': 1.7.5 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) - '@floating-ui/utils@0.2.11': {} + '@floating-ui/utils@0.2.10': {} - '@hono/node-server@1.19.14(hono@4.12.25)': + '@hono/node-server@1.19.9(hono@4.11.9)': dependencies: - hono: 4.12.25 + hono: 4.11.9 - '@humanfs/core@0.19.2': - dependencies: - '@humanfs/types': 0.15.0 + '@humanfs/core@0.19.1': {} - '@humanfs/node@0.16.8': + '@humanfs/node@0.16.7': dependencies: - '@humanfs/core': 0.19.2 - '@humanfs/types': 0.15.0 + '@humanfs/core': 0.19.1 '@humanwhocodes/retry': 0.4.3 - '@humanfs/types@0.15.0': {} - '@humanwhocodes/module-importer@1.0.1': {} '@humanwhocodes/retry@0.4.3': {} - '@img/colour@1.1.0': {} + '@img/colour@1.0.0': {} '@img/sharp-darwin-arm64@0.34.5': optionalDependencies: @@ -5761,7 +6283,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.11.1 + '@emnapi/runtime': 1.8.1 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -5773,32 +6295,39 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true - '@inquirer/ansi@2.0.7': {} + '@inquirer/ansi@1.0.2': {} - '@inquirer/confirm@6.1.1(@types/node@25.9.3)': + '@inquirer/confirm@5.1.21(@types/node@25.2.3)': dependencies: - '@inquirer/core': 11.2.1(@types/node@25.9.3) - '@inquirer/type': 4.0.7(@types/node@25.9.3) + '@inquirer/core': 10.3.2(@types/node@25.2.3) + '@inquirer/type': 3.0.10(@types/node@25.2.3) optionalDependencies: - '@types/node': 25.9.3 + '@types/node': 25.2.3 - '@inquirer/core@11.2.1(@types/node@25.9.3)': + '@inquirer/core@10.3.2(@types/node@25.2.3)': dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@25.9.3) + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@25.2.3) cli-width: 4.1.0 - fast-wrap-ansi: 0.2.2 - mute-stream: 3.0.0 + mute-stream: 2.0.0 signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 25.9.3 + '@types/node': 25.2.3 - '@inquirer/figures@2.0.7': {} + '@inquirer/figures@1.0.15': {} - '@inquirer/type@4.0.7(@types/node@25.9.3)': + '@inquirer/type@3.0.10(@types/node@25.2.3)': optionalDependencies: - '@types/node': 25.9.3 + '@types/node': 25.2.3 + + '@isaacs/balanced-match@4.0.1': {} + + '@isaacs/brace-expansion@5.0.1': + dependencies: + '@isaacs/balanced-match': 4.0.1 '@isaacs/cliui@9.0.0': {} @@ -5833,46 +6362,46 @@ snapshots: '@modelcontextprotocol/sdk@1.24.3(zod@3.25.76)': dependencies: - ajv: 8.20.0 - ajv-formats: 3.0.1(ajv@8.20.0) + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) content-type: 1.0.5 cors: 2.8.6 cross-spawn: 7.0.6 eventsource: 3.0.7 - eventsource-parser: 3.1.0 + eventsource-parser: 3.0.6 express: 5.2.1 express-rate-limit: 7.5.1(express@5.2.1) - jose: 6.2.3 + jose: 6.1.3 pkce-challenge: 5.0.1 raw-body: 3.0.2 zod: 3.25.76 - zod-to-json-schema: 3.25.2(zod@3.25.76) + zod-to-json-schema: 3.25.1(zod@3.25.76) transitivePeerDependencies: - supports-color - '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': + '@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.14(hono@4.12.25) - ajv: 8.20.0 - ajv-formats: 3.0.1(ajv@8.20.0) + '@hono/node-server': 1.19.9(hono@4.11.9) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) content-type: 1.0.5 cors: 2.8.6 cross-spawn: 7.0.6 eventsource: 3.0.7 - eventsource-parser: 3.1.0 + eventsource-parser: 3.0.6 express: 5.2.1 - express-rate-limit: 8.5.2(express@5.2.1) - hono: 4.12.25 - jose: 6.2.3 + express-rate-limit: 8.2.1(express@5.2.1) + hono: 4.11.9 + jose: 6.1.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 zod: 3.25.76 - zod-to-json-schema: 3.25.2(zod@3.25.76) + zod-to-json-schema: 3.25.1(zod@3.25.76) transitivePeerDependencies: - supports-color - '@mswjs/interceptors@0.41.9': + '@mswjs/interceptors@0.41.3': dependencies: '@open-draft/deferred-promise': 2.2.0 '@open-draft/logger': 0.3.0 @@ -5881,41 +6410,41 @@ snapshots: outvariant: 1.4.3 strict-event-emitter: 0.5.1 - '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/wasm-runtime@0.2.12': dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.2 + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 + '@tybys/wasm-util': 0.10.1 optional: true - '@next/env@16.2.9': {} + '@next/env@16.1.6': {} - '@next/eslint-plugin-next@16.2.9': + '@next/eslint-plugin-next@16.1.6': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@16.2.9': + '@next/swc-darwin-arm64@16.1.6': optional: true - '@next/swc-darwin-x64@16.2.9': + '@next/swc-darwin-x64@16.1.6': optional: true - '@next/swc-linux-arm64-gnu@16.2.9': + '@next/swc-linux-arm64-gnu@16.1.6': optional: true - '@next/swc-linux-arm64-musl@16.2.9': + '@next/swc-linux-arm64-musl@16.1.6': optional: true - '@next/swc-linux-x64-gnu@16.2.9': + '@next/swc-linux-x64-gnu@16.1.6': optional: true - '@next/swc-linux-x64-musl@16.2.9': + '@next/swc-linux-x64-musl@16.1.6': optional: true - '@next/swc-win32-arm64-msvc@16.2.9': + '@next/swc-win32-arm64-msvc@16.1.6': optional: true - '@next/swc-win32-x64-msvc@16.2.9': + '@next/swc-win32-x64-msvc@16.1.6': optional: true '@noble/ciphers@1.3.0': {} @@ -5926,8 +6455,6 @@ snapshots: '@noble/hashes@1.8.0': {} - '@nodable/entities@2.2.0': {} - '@node-minify/core@8.0.6': dependencies: '@node-minify/utils': 8.0.6 @@ -5959,8 +6486,6 @@ snapshots: '@open-draft/deferred-promise@2.2.0': {} - '@open-draft/deferred-promise@3.0.0': {} - '@open-draft/logger@0.3.0': dependencies: is-node-process: 1.2.0 @@ -5968,7 +6493,7 @@ snapshots: '@open-draft/until@2.1.0': {} - '@opennextjs/aws@4.0.2(next@16.2.9(@babel/core@7.29.7)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))': + '@opennextjs/aws@3.9.16(next@16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': dependencies: '@ast-grep/napi': 0.40.5 '@aws-sdk/client-cloudfront': 3.984.0 @@ -5984,28 +6509,28 @@ snapshots: cookie: 1.1.1 esbuild: 0.25.4 express: 5.2.1 - next: 16.2.9(@babel/core@7.29.7)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) path-to-regexp: 6.3.0 urlpattern-polyfill: 10.1.0 - yaml: 2.9.0 + yaml: 2.8.2 transitivePeerDependencies: + - aws-crt - supports-color - '@opennextjs/cloudflare@1.19.11(next@16.2.9(@babel/core@7.29.7)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(wrangler@4.100.0)': + '@opennextjs/cloudflare@1.16.5(next@16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(wrangler@4.66.0)': dependencies: '@ast-grep/napi': 0.40.5 '@dotenvx/dotenvx': 1.31.0 - '@opennextjs/aws': 4.0.2(next@16.2.9(@babel/core@7.29.7)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)) - ci-info: 4.4.0 + '@opennextjs/aws': 3.9.16(next@16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) cloudflare: 4.5.0 - comment-json: 4.6.2 enquirer: 2.4.1 glob: 12.0.0 - next: 16.2.9(@babel/core@7.29.7)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) ts-tqdm: 0.8.6 - wrangler: 4.100.0 + wrangler: 4.66.0 yargs: 18.0.0 transitivePeerDependencies: + - aws-crt - encoding - supports-color @@ -6021,6 +6546,19 @@ snapshots: '@poppinss/exception@1.2.3': {} + '@preact/signals-core@1.13.0': {} + + '@preact/signals@1.3.4(preact@10.28.3)': + dependencies: + '@preact/signals-core': 1.13.0 + preact: 10.28.3 + + '@rollup/pluginutils@5.3.0': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.3 + '@rtsao/scc@1.1.0': {} '@sec-ant/readable-stream@0.4.1': {} @@ -6029,148 +6567,255 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} - '@smithy/config-resolver@4.5.7': + '@smithy/abort-controller@4.2.8': dependencies: - '@smithy/core': 3.24.7 + '@smithy/types': 4.12.0 tslib: 2.8.1 - '@smithy/core@3.24.7': + '@smithy/chunked-blob-reader-native@4.2.1': + dependencies: + '@smithy/util-base64': 4.3.0 + tslib: 2.8.1 + + '@smithy/chunked-blob-reader@5.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/config-resolver@4.4.6': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + tslib: 2.8.1 + + '@smithy/core@3.23.0': + dependencies: + '@smithy/middleware-serde': 4.2.9 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-stream': 4.5.12 + '@smithy/util-utf8': 4.2.0 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.2.8': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/property-provider': 4.2.8 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + tslib: 2.8.1 + + '@smithy/eventstream-codec@4.2.8': dependencies: '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.14.4 + '@smithy/types': 4.12.0 + '@smithy/util-hex-encoding': 4.2.0 tslib: 2.8.1 - '@smithy/credential-provider-imds@4.3.9': + '@smithy/eventstream-serde-browser@4.2.8': dependencies: - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@smithy/eventstream-serde-universal': 4.2.8 + '@smithy/types': 4.12.0 tslib: 2.8.1 - '@smithy/eventstream-serde-browser@4.3.7': + '@smithy/eventstream-serde-config-resolver@4.3.8': dependencies: - '@smithy/core': 3.24.7 + '@smithy/types': 4.12.0 tslib: 2.8.1 - '@smithy/eventstream-serde-config-resolver@4.4.7': + '@smithy/eventstream-serde-node@4.2.8': dependencies: - '@smithy/core': 3.24.7 + '@smithy/eventstream-serde-universal': 4.2.8 + '@smithy/types': 4.12.0 tslib: 2.8.1 - '@smithy/eventstream-serde-node@4.3.7': + '@smithy/eventstream-serde-universal@4.2.8': dependencies: - '@smithy/core': 3.24.7 + '@smithy/eventstream-codec': 4.2.8 + '@smithy/types': 4.12.0 tslib: 2.8.1 - '@smithy/fetch-http-handler@5.4.7': + '@smithy/fetch-http-handler@5.3.9': dependencies: - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 + '@smithy/protocol-http': 5.3.8 + '@smithy/querystring-builder': 4.2.8 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 tslib: 2.8.1 - '@smithy/hash-blob-browser@4.3.7': + '@smithy/hash-blob-browser@4.2.9': dependencies: - '@smithy/core': 3.24.7 + '@smithy/chunked-blob-reader': 5.2.0 + '@smithy/chunked-blob-reader-native': 4.2.1 + '@smithy/types': 4.12.0 tslib: 2.8.1 - '@smithy/hash-node@4.3.7': + '@smithy/hash-node@4.2.8': dependencies: - '@smithy/core': 3.24.7 + '@smithy/types': 4.12.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 - '@smithy/hash-stream-node@4.3.7': + '@smithy/hash-stream-node@4.2.8': dependencies: - '@smithy/core': 3.24.7 + '@smithy/types': 4.12.0 + '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 - '@smithy/invalid-dependency@4.3.7': + '@smithy/invalid-dependency@4.2.8': dependencies: - '@smithy/core': 3.24.7 + '@smithy/types': 4.12.0 tslib: 2.8.1 '@smithy/is-array-buffer@2.2.0': dependencies: tslib: 2.8.1 - '@smithy/md5-js@4.3.7': - dependencies: - '@smithy/core': 3.24.7 - tslib: 2.8.1 - - '@smithy/middleware-content-length@4.3.7': - dependencies: - '@smithy/core': 3.24.7 - tslib: 2.8.1 - - '@smithy/middleware-endpoint@4.5.7': - dependencies: - '@smithy/core': 3.24.7 - tslib: 2.8.1 - - '@smithy/middleware-retry@4.6.7': - dependencies: - '@smithy/core': 3.24.7 - tslib: 2.8.1 - - '@smithy/middleware-serde@4.3.7': - dependencies: - '@smithy/core': 3.24.7 - tslib: 2.8.1 - - '@smithy/middleware-stack@4.3.7': - dependencies: - '@smithy/core': 3.24.7 - tslib: 2.8.1 - - '@smithy/node-config-provider@4.4.7': - dependencies: - '@smithy/core': 3.24.7 - tslib: 2.8.1 - - '@smithy/node-http-handler@4.7.8': - dependencies: - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 - tslib: 2.8.1 - - '@smithy/protocol-http@5.4.7': - dependencies: - '@smithy/core': 3.24.7 - tslib: 2.8.1 - - '@smithy/signature-v4@5.4.7': - dependencies: - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 - tslib: 2.8.1 - - '@smithy/smithy-client@4.13.7': - dependencies: - '@smithy/core': 3.24.7 - '@smithy/types': 4.14.4 - tslib: 2.8.1 - - '@smithy/types@4.14.4': + '@smithy/is-array-buffer@4.2.0': dependencies: tslib: 2.8.1 - '@smithy/url-parser@4.3.7': + '@smithy/md5-js@4.2.8': dependencies: - '@smithy/core': 3.24.7 + '@smithy/types': 4.12.0 + '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 - '@smithy/util-base64@4.4.7': + '@smithy/middleware-content-length@4.2.8': dependencies: - '@smithy/core': 3.24.7 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 tslib: 2.8.1 - '@smithy/util-body-length-browser@4.3.7': + '@smithy/middleware-endpoint@4.4.14': dependencies: - '@smithy/core': 3.24.7 + '@smithy/core': 3.23.0 + '@smithy/middleware-serde': 4.2.9 + '@smithy/node-config-provider': 4.3.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-middleware': 4.2.8 tslib: 2.8.1 - '@smithy/util-body-length-node@4.3.7': + '@smithy/middleware-retry@4.4.31': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/service-error-classification': 4.2.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.2.9': + dependencies: + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/middleware-stack@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/node-config-provider@4.3.8': + dependencies: + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.4.10': + dependencies: + '@smithy/abort-controller': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/querystring-builder': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/property-provider@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/protocol-http@5.3.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/querystring-builder@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + '@smithy/util-uri-escape': 4.2.0 + tslib: 2.8.1 + + '@smithy/querystring-parser@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/service-error-classification@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + + '@smithy/shared-ini-file-loader@4.4.3': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.3.8': + dependencies: + '@smithy/is-array-buffer': 4.2.0 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-uri-escape': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/smithy-client@4.11.3': + dependencies: + '@smithy/core': 3.23.0 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-stack': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-stream': 4.5.12 + tslib: 2.8.1 + + '@smithy/types@4.12.0': + dependencies: + tslib: 2.8.1 + + '@smithy/url-parser@4.2.8': + dependencies: + '@smithy/querystring-parser': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-base64@4.3.0': + dependencies: + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-body-length-browser@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-body-length-node@4.2.1': dependencies: - '@smithy/core': 3.24.7 tslib: 2.8.1 '@smithy/util-buffer-from@2.2.0': @@ -6178,34 +6823,66 @@ snapshots: '@smithy/is-array-buffer': 2.2.0 tslib: 2.8.1 - '@smithy/util-defaults-mode-browser@4.4.7': + '@smithy/util-buffer-from@4.2.0': dependencies: - '@smithy/core': 3.24.7 + '@smithy/is-array-buffer': 4.2.0 tslib: 2.8.1 - '@smithy/util-defaults-mode-node@4.3.7': + '@smithy/util-config-provider@4.2.0': dependencies: - '@smithy/core': 3.24.7 tslib: 2.8.1 - '@smithy/util-endpoints@3.5.7': + '@smithy/util-defaults-mode-browser@4.3.30': dependencies: - '@smithy/core': 3.24.7 + '@smithy/property-provider': 4.2.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 tslib: 2.8.1 - '@smithy/util-middleware@4.3.7': + '@smithy/util-defaults-mode-node@4.2.33': dependencies: - '@smithy/core': 3.24.7 + '@smithy/config-resolver': 4.4.6 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/property-provider': 4.2.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 tslib: 2.8.1 - '@smithy/util-retry@4.4.7': + '@smithy/util-endpoints@3.2.8': dependencies: - '@smithy/core': 3.24.7 + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 tslib: 2.8.1 - '@smithy/util-stream@4.6.7': + '@smithy/util-hex-encoding@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-middleware@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-retry@4.2.8': + dependencies: + '@smithy/service-error-classification': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-stream@4.5.12': + dependencies: + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/node-http-handler': 4.4.10 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-uri-escape@4.2.0': dependencies: - '@smithy/core': 3.24.7 tslib: 2.8.1 '@smithy/util-utf8@2.3.0': @@ -6213,105 +6890,115 @@ snapshots: '@smithy/util-buffer-from': 2.2.0 tslib: 2.8.1 - '@smithy/util-utf8@4.3.7': + '@smithy/util-utf8@4.2.0': dependencies: - '@smithy/core': 3.24.7 + '@smithy/util-buffer-from': 4.2.0 tslib: 2.8.1 - '@smithy/util-waiter@4.4.7': + '@smithy/util-waiter@4.2.8': dependencies: - '@smithy/core': 3.24.7 + '@smithy/abort-controller': 4.2.8 + '@smithy/types': 4.12.0 tslib: 2.8.1 - '@speed-highlight/core@1.2.17': {} + '@smithy/uuid@1.1.0': + dependencies: + tslib: 2.8.1 + + '@speed-highlight/core@1.2.14': {} '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 - '@tailwindcss/node@4.3.1': + '@tailwindcss/node@4.1.18': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.21.6 - jiti: 2.7.0 - lightningcss: 1.32.0 + enhanced-resolve: 5.19.0 + jiti: 2.6.1 + lightningcss: 1.30.2 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.3.1 + tailwindcss: 4.1.18 - '@tailwindcss/oxide-android-arm64@4.3.1': + '@tailwindcss/oxide-android-arm64@4.1.18': optional: true - '@tailwindcss/oxide-darwin-arm64@4.3.1': + '@tailwindcss/oxide-darwin-arm64@4.1.18': optional: true - '@tailwindcss/oxide-darwin-x64@4.3.1': + '@tailwindcss/oxide-darwin-x64@4.1.18': optional: true - '@tailwindcss/oxide-freebsd-x64@4.3.1': + '@tailwindcss/oxide-freebsd-x64@4.1.18': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.3.1': + '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.3.1': + '@tailwindcss/oxide-linux-arm64-musl@4.1.18': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.3.1': + '@tailwindcss/oxide-linux-x64-gnu@4.1.18': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.3.1': + '@tailwindcss/oxide-linux-x64-musl@4.1.18': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.3.1': + '@tailwindcss/oxide-wasm32-wasi@4.1.18': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.3.1': + '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.3.1': + '@tailwindcss/oxide-win32-x64-msvc@4.1.18': optional: true - '@tailwindcss/oxide@4.3.1': + '@tailwindcss/oxide@4.1.18': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.3.1 - '@tailwindcss/oxide-darwin-arm64': 4.3.1 - '@tailwindcss/oxide-darwin-x64': 4.3.1 - '@tailwindcss/oxide-freebsd-x64': 4.3.1 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.1 - '@tailwindcss/oxide-linux-arm64-gnu': 4.3.1 - '@tailwindcss/oxide-linux-arm64-musl': 4.3.1 - '@tailwindcss/oxide-linux-x64-gnu': 4.3.1 - '@tailwindcss/oxide-linux-x64-musl': 4.3.1 - '@tailwindcss/oxide-wasm32-wasi': 4.3.1 - '@tailwindcss/oxide-win32-arm64-msvc': 4.3.1 - '@tailwindcss/oxide-win32-x64-msvc': 4.3.1 + '@tailwindcss/oxide-android-arm64': 4.1.18 + '@tailwindcss/oxide-darwin-arm64': 4.1.18 + '@tailwindcss/oxide-darwin-x64': 4.1.18 + '@tailwindcss/oxide-freebsd-x64': 4.1.18 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.18 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.18 + '@tailwindcss/oxide-linux-x64-musl': 4.1.18 + '@tailwindcss/oxide-wasm32-wasi': 4.1.18 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - '@tailwindcss/postcss@4.3.1': + '@tailwindcss/postcss@4.1.18': dependencies: '@alloc/quick-lru': 5.2.0 - '@tailwindcss/node': 4.3.1 - '@tailwindcss/oxide': 4.3.1 - postcss: 8.5.15 - tailwindcss: 4.3.1 + '@tailwindcss/node': 4.1.18 + '@tailwindcss/oxide': 4.1.18 + postcss: 8.5.6 + tailwindcss: 4.1.18 + + '@tailwindcss/typography@0.5.19(tailwindcss@4.1.18)': + dependencies: + postcss-selector-parser: 6.0.10 + tailwindcss: 4.1.18 '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 - minimatch: 10.2.5 + minimatch: 10.2.1 path-browserify: 1.0.1 '@tsconfig/node18@1.0.3': {} - '@tybys/wasm-util@0.10.2': + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 optional: true - '@types/estree@1.0.9': {} + '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} @@ -6319,192 +7006,248 @@ snapshots: '@types/node-fetch@2.6.13': dependencies: - '@types/node': 25.9.3 - form-data: 4.0.6 + '@types/node': 25.2.3 + form-data: 4.0.5 '@types/node@18.19.130': dependencies: undici-types: 5.26.5 - '@types/node@25.9.3': + '@types/node@20.19.33': dependencies: - undici-types: 7.24.6 + undici-types: 6.21.0 - '@types/react-dom@19.2.3(@types/react@19.2.17)': + '@types/node@25.2.3': dependencies: - '@types/react': 19.2.17 + undici-types: 7.16.0 - '@types/react@19.2.17': + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react-reconciler@0.28.9(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': dependencies: csstype: 3.2.3 - '@types/set-cookie-parser@2.4.10': - dependencies: - '@types/node': 25.9.3 - '@types/statuses@2.0.6': {} '@types/validate-npm-package-name@4.0.2': {} - '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/type-utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.61.0 - eslint: 9.39.4(jiti@2.7.0) + '@typescript-eslint/parser': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/type-utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.0 + eslint: 9.39.2(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.9.3) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.61.0 + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.0 debug: 4.4.3 - eslint: 9.39.4(jiti@2.7.0) + eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.61.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.55.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3) - '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.61.0': + '@typescript-eslint/project-service@8.56.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/visitor-keys': 8.61.0 - - '@typescript-eslint/tsconfig-utils@8.61.0(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - - '@typescript-eslint/type-utils@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) + '@typescript-eslint/types': 8.56.0 debug: 4.4.3 - eslint: 9.39.4(jiti@2.7.0) - ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.61.0': {} - - '@typescript-eslint/typescript-estree@8.61.0(typescript@5.9.3)': + '@typescript-eslint/scope-manager@8.55.0': dependencies: - '@typescript-eslint/project-service': 8.61.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3) - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/visitor-keys': 8.61.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/visitor-keys': 8.55.0 + + '@typescript-eslint/scope-manager@8.56.0': + dependencies: + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/visitor-keys': 8.56.0 + + '@typescript-eslint/tsconfig-utils@8.55.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/tsconfig-utils@8.56.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 - minimatch: 10.2.5 - semver: 7.8.4 - tinyglobby: 0.2.17 - ts-api-utils: 2.5.0(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) - eslint: 9.39.4(jiti@2.7.0) + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.2(jiti@2.6.1) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.61.0': + '@typescript-eslint/types@8.55.0': {} + + '@typescript-eslint/types@8.56.0': {} + + '@typescript-eslint/typescript-estree@8.55.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.61.0 - eslint-visitor-keys: 5.0.1 + '@typescript-eslint/project-service': 8.55.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/visitor-keys': 8.55.0 + debug: 4.4.3 + minimatch: 9.0.5 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color - '@unrs/resolver-binding-android-arm-eabi@1.12.2': - optional: true - - '@unrs/resolver-binding-android-arm64@1.12.2': - optional: true - - '@unrs/resolver-binding-darwin-arm64@1.12.2': - optional: true - - '@unrs/resolver-binding-darwin-x64@1.12.2': - optional: true - - '@unrs/resolver-binding-freebsd-x64@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-arm64-musl@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-loong64-musl@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-x64-gnu@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-x64-musl@1.12.2': - optional: true - - '@unrs/resolver-binding-openharmony-arm64@1.12.2': - optional: true - - '@unrs/resolver-binding-wasm32-wasi@1.12.2': + '@typescript-eslint/typescript-estree@8.56.0(typescript@5.9.3)': dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@typescript-eslint/project-service': 8.56.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/visitor-keys': 8.56.0 + debug: 4.4.3 + minimatch: 9.0.5 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.55.0': + dependencies: + '@typescript-eslint/types': 8.55.0 + eslint-visitor-keys: 4.2.1 + + '@typescript-eslint/visitor-keys@8.56.0': + dependencies: + '@typescript-eslint/types': 8.56.0 + eslint-visitor-keys: 5.0.0 + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': optional: true - '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + '@unrs/resolver-binding-android-arm64@1.11.1': optional: true - '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + '@unrs/resolver-binding-darwin-arm64@1.11.1': optional: true - '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + '@unrs/resolver-binding-darwin-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true abort-controller@3.0.0: @@ -6516,11 +7259,11 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - acorn-jsx@5.3.2(acorn@8.17.0): + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: - acorn: 8.17.0 + acorn: 8.15.0 - acorn@8.17.0: {} + acorn@8.15.0: {} agent-base@7.1.4: {} @@ -6528,21 +7271,32 @@ snapshots: dependencies: humanize-ms: 1.2.1 - ajv-formats@3.0.1(ajv@8.20.0): + ajv-formats@3.0.1(ajv@8.17.1): optionalDependencies: - ajv: 8.20.0 + ajv: 8.17.1 - ajv@6.15.0: + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.20.0: + ajv@8.17.1: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -6558,9 +7312,7 @@ snapshots: ansi-styles@6.2.3: {} - ansis@4.3.1: {} - - anynum@1.0.0: {} + ansis@4.2.0: {} argparse@2.0.1: {} @@ -6573,64 +7325,62 @@ snapshots: array-includes@3.1.9: dependencies: - call-bind: 1.0.9 + call-bind: 1.0.8 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.2 - es-object-atoms: 1.1.2 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 get-intrinsic: 1.3.0 is-string: 1.1.1 math-intrinsics: 1.1.0 - array-timsort@1.0.3: {} - array.prototype.findlast@1.2.5: dependencies: - call-bind: 1.0.9 + call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.2 + es-abstract: 1.24.1 es-errors: 1.3.0 - es-object-atoms: 1.1.2 + es-object-atoms: 1.1.1 es-shim-unscopables: 1.1.0 array.prototype.findlastindex@1.2.6: dependencies: - call-bind: 1.0.9 + call-bind: 1.0.8 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.2 + es-abstract: 1.24.1 es-errors: 1.3.0 - es-object-atoms: 1.1.2 + es-object-atoms: 1.1.1 es-shim-unscopables: 1.1.0 array.prototype.flat@1.3.3: dependencies: - call-bind: 1.0.9 + call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.2 + es-abstract: 1.24.1 es-shim-unscopables: 1.1.0 array.prototype.flatmap@1.3.3: dependencies: - call-bind: 1.0.9 + call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.2 + es-abstract: 1.24.1 es-shim-unscopables: 1.1.0 array.prototype.tosorted@1.1.4: dependencies: - call-bind: 1.0.9 + call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.2 + es-abstract: 1.24.1 es-errors: 1.3.0 es-shim-unscopables: 1.1.0 arraybuffer.prototype.slice@1.0.4: dependencies: array-buffer-byte-length: 1.0.2 - call-bind: 1.0.9 + call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.2 + es-abstract: 1.24.1 es-errors: 1.3.0 get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 @@ -6651,19 +7401,28 @@ snapshots: aws4fetch@1.0.20: {} - axe-core@4.12.1: {} + axe-core@4.11.1: {} axobject-query@4.1.0: {} babel-plugin-react-compiler@1.0.0: dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.0 balanced-match@1.0.2: {} - balanced-match@4.0.4: {} + balanced-match@4.0.2: + dependencies: + jackspeak: 4.2.3 - baseline-browser-mapping@2.10.37: {} + baseline-browser-mapping@2.9.19: {} + + bippy@0.5.30(@types/react@19.2.14)(react@19.2.4): + dependencies: + '@types/react-reconciler': 0.28.9(@types/react@19.2.14) + react: 19.2.4 + transitivePeerDependencies: + - '@types/react' birecord@0.1.1: {} @@ -6677,38 +7436,38 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 - qs: 6.15.2 + qs: 6.14.1 raw-body: 3.0.2 - type-is: 2.1.0 + type-is: 2.0.1 transitivePeerDependencies: - supports-color bowser@2.14.1: {} - brace-expansion@1.1.15: + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.1.1: + brace-expansion@2.0.2: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.6: + brace-expansion@5.0.2: dependencies: - balanced-match: 4.0.4 + balanced-match: 4.0.2 braces@3.0.3: dependencies: fill-range: 7.1.1 - browserslist@4.28.2: + browserslist@4.28.1: dependencies: - baseline-browser-mapping: 2.10.37 - caniuse-lite: 1.0.30001799 - electron-to-chromium: 1.5.372 - node-releases: 2.0.47 - update-browserslist-db: 1.2.3(browserslist@4.28.2) + baseline-browser-mapping: 2.9.19 + caniuse-lite: 1.0.30001769 + electron-to-chromium: 1.5.286 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) buffer-from@1.1.2: {} @@ -6723,7 +7482,7 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 - call-bind@1.0.9: + call-bind@1.0.8: dependencies: call-bind-apply-helpers: 1.0.2 es-define-property: 1.0.1 @@ -6737,7 +7496,7 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001799: {} + caniuse-lite@1.0.30001769: {} chalk@4.1.2: dependencies: @@ -6746,8 +7505,6 @@ snapshots: chalk@5.6.2: {} - ci-info@4.4.0: {} - class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -6771,7 +7528,7 @@ snapshots: cliui@9.0.1: dependencies: string-width: 7.2.0 - strip-ansi: 7.2.0 + strip-ansi: 7.1.2 wrap-ansi: 9.0.2 cloudflare@4.5.0: @@ -6808,21 +7565,14 @@ snapshots: commander@2.20.3: {} - comment-json@4.6.2: - dependencies: - array-timsort: 1.0.3 - esprima: 4.0.1 - compare-versions@6.1.1: {} concat-map@0.0.1: {} - content-disposition@1.1.0: {} + content-disposition@1.0.1: {} content-type@1.0.5: {} - content-type@2.0.0: {} - convert-source-map@2.0.0: {} cookie-signature@1.2.2: {} @@ -6836,11 +7586,11 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 - cosmiconfig@9.0.2(typescript@5.9.3): + cosmiconfig@9.0.0(typescript@5.9.3): dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 - js-yaml: 4.2.0 + js-yaml: 4.1.1 parse-json: 5.2.0 optionalDependencies: typescript: 5.9.3 @@ -6885,7 +7635,7 @@ snapshots: dependencies: ms: 2.1.3 - dedent@1.7.2: {} + dedent@1.7.1: {} deep-is@0.1.4: {} @@ -6918,7 +7668,7 @@ snapshots: detect-libc@2.1.2: {} - diff@8.0.4: {} + diff@8.0.3: {} doctrine@2.1.0: dependencies: @@ -6926,7 +7676,7 @@ snapshots: dotenv@16.6.1: {} - dotenv@17.4.2: {} + dotenv@17.3.1: {} dunder-proto@1.0.1: dependencies: @@ -6936,16 +7686,16 @@ snapshots: duplexer@0.1.2: {} - eciesjs@0.4.18: + eciesjs@0.4.17: dependencies: - '@ecies/ciphers': 0.2.6(@noble/ciphers@1.3.0) + '@ecies/ciphers': 0.2.5(@noble/ciphers@1.3.0) '@noble/ciphers': 1.3.0 '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 ee-first@1.1.1: {} - electron-to-chromium@1.5.372: {} + electron-to-chromium@1.5.286: {} emoji-regex@10.6.0: {} @@ -6955,10 +7705,10 @@ snapshots: encodeurl@2.0.0: {} - enhanced-resolve@5.21.6: + enhanced-resolve@5.19.0: dependencies: graceful-fs: 4.2.11 - tapable: 2.3.3 + tapable: 2.3.0 enquirer@2.4.1: dependencies: @@ -6973,22 +7723,22 @@ snapshots: error-stack-parser-es@1.0.5: {} - es-abstract@1.24.2: + es-abstract@1.24.1: dependencies: array-buffer-byte-length: 1.0.2 arraybuffer.prototype.slice: 1.0.4 available-typed-arrays: 1.0.7 - call-bind: 1.0.9 + call-bind: 1.0.8 call-bound: 1.0.4 data-view-buffer: 1.0.2 data-view-byte-length: 1.0.2 data-view-byte-offset: 1.0.1 es-define-property: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.2 + es-object-atoms: 1.1.1 es-set-tostringtag: 2.1.0 es-to-primitive: 1.3.0 - function.prototype.name: 1.2.0 + function.prototype.name: 1.1.8 get-intrinsic: 1.3.0 get-proto: 1.0.1 get-symbol-description: 1.1.0 @@ -6997,7 +7747,7 @@ snapshots: has-property-descriptors: 1.0.2 has-proto: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.4 + hasown: 2.0.2 internal-slot: 1.1.0 is-array-buffer: 3.0.5 is-callable: 1.2.7 @@ -7015,31 +7765,31 @@ snapshots: object.assign: 4.1.7 own-keys: 1.0.1 regexp.prototype.flags: 1.5.4 - safe-array-concat: 1.1.4 + safe-array-concat: 1.1.3 safe-push-apply: 1.0.0 safe-regex-test: 1.1.0 set-proto: 1.0.0 stop-iteration-iterator: 1.1.0 - string.prototype.trim: 1.2.11 - string.prototype.trimend: 1.0.10 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 string.prototype.trimstart: 1.0.8 typed-array-buffer: 1.0.3 typed-array-byte-length: 1.0.3 typed-array-byte-offset: 1.0.4 - typed-array-length: 1.0.8 + typed-array-length: 1.0.7 unbox-primitive: 1.1.0 - which-typed-array: 1.1.22 + which-typed-array: 1.1.20 es-define-property@1.0.1: {} es-errors@1.3.0: {} - es-iterator-helpers@1.3.3: + es-iterator-helpers@1.2.2: dependencies: - call-bind: 1.0.9 + call-bind: 1.0.8 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.2 + es-abstract: 1.24.1 es-errors: 1.3.0 es-set-tostringtag: 2.1.0 function-bind: 1.1.2 @@ -7051,9 +7801,9 @@ snapshots: has-symbols: 1.1.0 internal-slot: 1.1.0 iterator.prototype: 1.1.5 - math-intrinsics: 1.1.0 + safe-array-concat: 1.1.3 - es-object-atoms@1.1.2: + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -7062,11 +7812,11 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.4 + hasown: 2.0.2 es-shim-unscopables@1.1.0: dependencies: - hasown: 2.0.4 + hasown: 2.0.2 es-to-primitive@1.3.0: dependencies: @@ -7074,6 +7824,35 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + esbuild@0.25.4: optionalDependencies: '@esbuild/aix-ppc64': 0.25.4 @@ -7137,18 +7916,18 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-next@16.2.9(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): + eslint-config-next@16.1.6(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@next/eslint-plugin-next': 16.2.9 - eslint: 9.39.4(jiti@2.7.0) - eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.7.0)) - eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.7.0)) - eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.7.0)) + '@next/eslint-plugin-next': 16.1.6 + eslint: 9.39.2(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) globals: 16.4.0 - typescript-eslint: 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + typescript-eslint: 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -7157,45 +7936,45 @@ snapshots: - eslint-plugin-import-x - supports-color - eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.7.0)): + eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)): dependencies: - eslint: 9.39.4(jiti@2.7.0) + eslint: 9.39.2(jiti@2.6.1) - eslint-import-resolver-node@0.3.10: + eslint-import-resolver-node@0.3.9: dependencies: debug: 3.2.7 - is-core-module: 2.16.2 - resolve: 2.0.0-next.7 + is-core-module: 2.16.1 + resolve: 1.22.11 transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 - eslint: 9.39.4(jiti@2.7.0) - get-tsconfig: 4.14.0 + eslint: 9.39.2(jiti@2.6.1) + get-tsconfig: 4.13.6 is-bun-module: 2.0.0 stable-hash: 0.0.5 - tinyglobby: 0.2.17 - unrs-resolver: 1.12.2 + tinyglobby: 0.2.15 + unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.13.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - eslint: 9.39.4(jiti@2.7.0) - eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) + '@typescript-eslint/parser': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -7204,182 +7983,182 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.39.4(jiti@2.7.0) - eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) - hasown: 2.0.4 - is-core-module: 2.16.2 + eslint: 9.39.2(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + hasown: 2.0.2 + is-core-module: 2.16.1 is-glob: 4.0.3 - minimatch: 3.1.5 + minimatch: 3.1.2 object.fromentries: 2.0.8 object.groupby: 1.0.3 object.values: 1.2.1 semver: 6.3.1 - string.prototype.trimend: 1.0.10 + string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@2.7.0)): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2(jiti@2.6.1)): dependencies: aria-query: 5.3.2 array-includes: 3.1.9 array.prototype.flatmap: 1.3.3 ast-types-flow: 0.0.8 - axe-core: 4.12.1 + axe-core: 4.11.1 axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.39.4(jiti@2.7.0) - hasown: 2.0.4 + eslint: 9.39.2(jiti@2.6.1) + hasown: 2.0.2 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 - minimatch: 3.1.5 + minimatch: 3.1.2 object.fromentries: 2.0.8 safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-dom@2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): + eslint-plugin-react-dom@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@eslint-react/core': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@eslint-react/ast': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/core': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@eslint-react/eff': 2.13.0 - '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@eslint-react/shared': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/var': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) compare-versions: 6.1.1 - eslint: 9.39.4(jiti@2.7.0) + eslint: 9.39.2(jiti@2.6.1) ts-pattern: 5.9.0 typescript: 5.9.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-hooks-extra@2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): + eslint-plugin-react-hooks-extra@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@eslint-react/core': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@eslint-react/ast': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/core': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@eslint-react/eff': 2.13.0 - '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/type-utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - eslint: 9.39.4(jiti@2.7.0) + '@eslint-react/shared': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/var': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/type-utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) ts-pattern: 5.9.0 typescript: 5.9.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-hooks@7.1.1(eslint@9.39.4(jiti@2.7.0)): + eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@2.6.1)): dependencies: - '@babel/core': 7.29.7 - '@babel/parser': 7.29.7 - eslint: 9.39.4(jiti@2.7.0) + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 + eslint: 9.39.2(jiti@2.6.1) hermes-parser: 0.25.1 - zod: 4.4.3 - zod-validation-error: 4.0.2(zod@4.4.3) + zod: 4.3.6 + zod-validation-error: 4.0.2(zod@4.3.6) transitivePeerDependencies: - supports-color - eslint-plugin-react-naming-convention@2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): + eslint-plugin-react-naming-convention@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@eslint-react/core': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@eslint-react/ast': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/core': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@eslint-react/eff': 2.13.0 - '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/type-utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@eslint-react/shared': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/var': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/type-utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) compare-versions: 6.1.1 - eslint: 9.39.4(jiti@2.7.0) + eslint: 9.39.2(jiti@2.6.1) string-ts: 2.3.1 ts-pattern: 5.9.0 typescript: 5.9.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-rsc@2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): + eslint-plugin-react-rsc@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - eslint: 9.39.4(jiti@2.7.0) + '@eslint-react/ast': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/shared': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/var': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) ts-pattern: 5.9.0 typescript: 5.9.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-web-api@2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): + eslint-plugin-react-web-api@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@eslint-react/core': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@eslint-react/ast': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/core': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@eslint-react/eff': 2.13.0 - '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@eslint-react/shared': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/var': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) birecord: 0.1.1 - eslint: 9.39.4(jiti@2.7.0) + eslint: 9.39.2(jiti@2.6.1) ts-pattern: 5.9.0 typescript: 5.9.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-x@2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): + eslint-plugin-react-x@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@eslint-react/core': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@eslint-react/ast': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/core': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@eslint-react/eff': 2.13.0 - '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/type-utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@eslint-react/shared': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/var': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/type-utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) compare-versions: 6.1.1 - eslint: 9.39.4(jiti@2.7.0) - is-immutable-type: 5.0.4(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - ts-api-utils: 2.5.0(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + is-immutable-type: 5.0.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + ts-api-utils: 2.4.0(typescript@5.9.3) ts-pattern: 5.9.0 typescript: 5.9.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-you-might-not-need-an-effect@0.9.3(eslint@9.39.4(jiti@2.7.0)): + eslint-plugin-react-you-might-not-need-an-effect@0.9.1(eslint@9.39.2(jiti@2.6.1)): dependencies: - eslint: 9.39.4(jiti@2.7.0) + eslint: 9.39.2(jiti@2.6.1) globals: 16.5.0 - eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.7.0)): + eslint-plugin-react@7.37.5(eslint@9.39.2(jiti@2.6.1)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 array.prototype.flatmap: 1.3.3 array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 - es-iterator-helpers: 1.3.3 - eslint: 9.39.4(jiti@2.7.0) + es-iterator-helpers: 1.2.2 + eslint: 9.39.2(jiti@2.6.1) estraverse: 5.3.0 - hasown: 2.0.4 + hasown: 2.0.2 jsx-ast-utils: 3.3.5 - minimatch: 3.1.5 + minimatch: 3.1.2 object.entries: 1.1.9 object.fromentries: 2.0.8 object.values: 1.2.1 prop-types: 15.8.1 - resolve: 2.0.0-next.7 + resolve: 2.0.0-next.5 semver: 6.3.1 string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 @@ -7393,23 +8172,23 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint-visitor-keys@5.0.1: {} + eslint-visitor-keys@5.0.0: {} - eslint@9.39.4(jiti@2.7.0): + eslint@9.39.2(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 + '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5 - '@eslint/js': 9.39.4 + '@eslint/eslintrc': 3.3.3 + '@eslint/js': 9.39.2 '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.8 + '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.9 - ajv: 6.15.0 + '@types/estree': 1.0.8 + ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 @@ -7428,18 +8207,18 @@ snapshots: is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - minimatch: 3.1.5 + minimatch: 3.1.2 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 2.7.0 + jiti: 2.6.1 transitivePeerDependencies: - supports-color espree@10.4.0: dependencies: - acorn: 8.17.0 - acorn-jsx: 5.3.2(acorn@8.17.0) + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) eslint-visitor-keys: 4.2.1 esprima@4.0.1: {} @@ -7454,17 +8233,23 @@ snapshots: estraverse@5.3.0: {} + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} etag@1.8.1: {} event-target-shim@5.0.1: {} - eventsource-parser@3.1.0: {} + eventsource-parser@3.0.6: {} eventsource@3.0.7: dependencies: - eventsource-parser: 3.1.0 + eventsource-parser: 3.0.6 execa@5.1.1: dependencies: @@ -7497,16 +8282,16 @@ snapshots: dependencies: express: 5.2.1 - express-rate-limit@8.5.2(express@5.2.1): + express-rate-limit@8.2.1(express@5.2.1): dependencies: express: 5.2.1 - ip-address: 10.2.0 + ip-address: 10.0.1 express@5.2.1: dependencies: accepts: 2.0.0 body-parser: 2.2.2 - content-disposition: 1.1.0 + content-disposition: 1.0.1 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 @@ -7524,13 +8309,13 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.15.2 + qs: 6.14.1 range-parser: 1.2.1 router: 2.2.0 send: 1.2.1 serve-static: 2.2.1 statuses: 2.0.2 - type-is: 2.1.0 + type-is: 2.0.1 vary: 1.1.2 transitivePeerDependencies: - supports-color @@ -7557,37 +8342,19 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-string-truncated-width@3.0.3: {} + fast-uri@3.1.0: {} - fast-string-width@3.0.2: + fast-xml-parser@5.3.4: dependencies: - fast-string-truncated-width: 3.0.3 - - fast-uri@3.1.2: {} - - fast-wrap-ansi@0.2.2: - dependencies: - fast-string-width: 3.0.2 - - fast-xml-builder@1.2.0: - dependencies: - path-expression-matcher: 1.5.0 - xml-naming: 0.1.0 - - fast-xml-parser@5.7.3: - dependencies: - '@nodable/entities': 2.2.0 - fast-xml-builder: 1.2.0 - path-expression-matcher: 1.5.0 - strnum: 2.4.0 + strnum: 2.1.2 fastq@1.20.1: dependencies: reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.4): + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: - picomatch: 4.0.4 + picomatch: 4.0.3 fetch-blob@3.2.0: dependencies: @@ -7630,10 +8397,10 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.4.2 + flatted: 3.3.3 keyv: 4.5.4 - flatted@3.4.2: {} + flatted@3.3.3: {} for-each@0.3.5: dependencies: @@ -7646,12 +8413,12 @@ snapshots: form-data-encoder@1.7.2: {} - form-data@4.0.6: + form-data@4.0.5: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.4 + hasown: 2.0.2 mime-types: 2.1.35 formdata-node@4.4.1: @@ -7665,21 +8432,21 @@ snapshots: forwarded@0.2.0: {} - framer-motion@12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + framer-motion@12.34.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - motion-dom: 12.40.0 - motion-utils: 12.39.0 + motion-dom: 12.34.1 + motion-utils: 12.29.2 tslib: 2.8.1 optionalDependencies: - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) fresh@2.0.0: {} - fs-extra@11.3.5: + fs-extra@11.3.3: dependencies: graceful-fs: 4.2.11 - jsonfile: 6.2.1 + jsonfile: 6.2.0 universalify: 2.0.1 fs.realpath@1.0.0: {} @@ -7689,17 +8456,14 @@ snapshots: function-bind@1.1.2: {} - function.prototype.name@1.2.0: + function.prototype.name@1.1.8: dependencies: - call-bind: 1.0.9 + call-bind: 1.0.8 call-bound: 1.0.4 - es-define-property: 1.0.1 - es-errors: 1.3.0 + define-properties: 1.2.1 functions-have-names: 1.2.3 - has-property-descriptors: 1.0.2 - hasown: 2.0.4 + hasown: 2.0.2 is-callable: 1.2.7 - is-document.all: 1.0.0 functions-have-names@1.2.3: {} @@ -7713,19 +8477,19 @@ snapshots: get-caller-file@2.0.5: {} - get-east-asian-width@1.6.0: {} + get-east-asian-width@1.4.0: {} get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 es-define-property: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.2 + es-object-atoms: 1.1.1 function-bind: 1.1.2 get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.4 + hasown: 2.0.2 math-intrinsics: 1.1.0 get-own-enumerable-keys@1.0.0: {} @@ -7733,7 +8497,7 @@ snapshots: get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 - es-object-atoms: 1.1.2 + es-object-atoms: 1.1.1 get-stream@6.0.1: {} @@ -7748,7 +8512,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 - get-tsconfig@4.14.0: + get-tsconfig@4.13.6: dependencies: resolve-pkg-maps: 1.0.0 @@ -7764,15 +8528,15 @@ snapshots: dependencies: foreground-child: 3.3.1 jackspeak: 4.2.3 - minimatch: 10.2.5 - minipass: 7.1.3 + minimatch: 10.1.2 + minipass: 7.1.2 package-json-from-dist: 1.0.1 - path-scurry: 2.0.2 + path-scurry: 2.0.1 glob@9.3.5: dependencies: fs.realpath: 1.0.0 - minimatch: 8.0.7 + minimatch: 8.0.4 minipass: 4.2.8 path-scurry: 1.11.1 @@ -7791,7 +8555,7 @@ snapshots: graceful-fs@4.2.11: {} - graphql@16.14.2: {} + graphql@16.12.0: {} gzip-size@6.0.0: dependencies: @@ -7815,14 +8579,11 @@ snapshots: dependencies: has-symbols: 1.1.0 - hasown@2.0.4: + hasown@2.0.2: dependencies: function-bind: 1.1.2 - headers-polyfill@5.0.1: - dependencies: - '@types/set-cookie-parser': 2.4.10 - set-cookie-parser: 3.1.0 + headers-polyfill@4.0.3: {} hermes-estree@0.25.1: {} @@ -7830,7 +8591,7 @@ snapshots: dependencies: hermes-estree: 0.25.1 - hono@4.12.25: {} + hono@4.11.9: {} http-errors@2.0.1: dependencies: @@ -7875,16 +8636,16 @@ snapshots: internal-slot@1.1.0: dependencies: es-errors: 1.3.0 - hasown: 2.0.4 - side-channel: 1.1.1 + hasown: 2.0.2 + side-channel: 1.1.0 - ip-address@10.2.0: {} + ip-address@10.0.1: {} ipaddr.js@1.9.1: {} is-array-buffer@3.0.5: dependencies: - call-bind: 1.0.9 + call-bind: 1.0.8 call-bound: 1.0.4 get-intrinsic: 1.3.0 @@ -7909,13 +8670,13 @@ snapshots: is-bun-module@2.0.0: dependencies: - semver: 7.8.4 + semver: 7.7.4 is-callable@1.2.7: {} - is-core-module@2.16.2: + is-core-module@2.16.1: dependencies: - hasown: 2.0.4 + hasown: 2.0.2 is-data-view@1.0.2: dependencies: @@ -7930,10 +8691,6 @@ snapshots: is-docker@3.0.0: {} - is-document.all@1.0.0: - dependencies: - call-bound: 1.0.4 - is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: @@ -7954,11 +8711,11 @@ snapshots: dependencies: is-extglob: 2.1.1 - is-immutable-type@5.0.4(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): + is-immutable-type@5.0.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/type-utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - eslint: 9.39.4(jiti@2.7.0) - ts-api-utils: 2.5.0(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + ts-api-utils: 2.4.0(typescript@5.9.3) ts-declaration-location: 1.0.7(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -7996,7 +8753,7 @@ snapshots: call-bound: 1.0.4 gopd: 1.2.0 has-tostringtag: 1.0.2 - hasown: 2.0.4 + hasown: 2.0.2 is-regexp@3.1.0: {} @@ -8023,7 +8780,7 @@ snapshots: is-typed-array@1.1.15: dependencies: - which-typed-array: 1.1.22 + which-typed-array: 1.1.20 is-unicode-supported@1.3.0: {} @@ -8048,12 +8805,12 @@ snapshots: isexe@2.0.0: {} - isexe@3.1.5: {} + isexe@3.1.4: {} iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 - es-object-atoms: 1.1.2 + es-object-atoms: 1.1.1 get-intrinsic: 1.3.0 get-proto: 1.0.1 has-symbols: 1.1.0 @@ -8063,13 +8820,13 @@ snapshots: dependencies: '@isaacs/cliui': 9.0.0 - jiti@2.7.0: {} + jiti@2.6.1: {} - jose@6.2.3: {} + jose@6.1.3: {} js-tokens@4.0.0: {} - js-yaml@4.2.0: + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -8093,7 +8850,7 @@ snapshots: json5@2.2.3: {} - jsonfile@6.2.1: + jsonfile@6.2.0: dependencies: universalify: 2.0.1 optionalDependencies: @@ -8125,54 +8882,54 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lightningcss-android-arm64@1.32.0: + lightningcss-android-arm64@1.30.2: optional: true - lightningcss-darwin-arm64@1.32.0: + lightningcss-darwin-arm64@1.30.2: optional: true - lightningcss-darwin-x64@1.32.0: + lightningcss-darwin-x64@1.30.2: optional: true - lightningcss-freebsd-x64@1.32.0: + lightningcss-freebsd-x64@1.30.2: optional: true - lightningcss-linux-arm-gnueabihf@1.32.0: + lightningcss-linux-arm-gnueabihf@1.30.2: optional: true - lightningcss-linux-arm64-gnu@1.32.0: + lightningcss-linux-arm64-gnu@1.30.2: optional: true - lightningcss-linux-arm64-musl@1.32.0: + lightningcss-linux-arm64-musl@1.30.2: optional: true - lightningcss-linux-x64-gnu@1.32.0: + lightningcss-linux-x64-gnu@1.30.2: optional: true - lightningcss-linux-x64-musl@1.32.0: + lightningcss-linux-x64-musl@1.30.2: optional: true - lightningcss-win32-arm64-msvc@1.32.0: + lightningcss-win32-arm64-msvc@1.30.2: optional: true - lightningcss-win32-x64-msvc@1.32.0: + lightningcss-win32-x64-msvc@1.30.2: optional: true - lightningcss@1.32.0: + lightningcss@1.30.2: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 + lightningcss-android-arm64: 1.30.2 + lightningcss-darwin-arm64: 1.30.2 + lightningcss-darwin-x64: 1.30.2 + lightningcss-freebsd-x64: 1.30.2 + lightningcss-linux-arm-gnueabihf: 1.30.2 + lightningcss-linux-arm64-gnu: 1.30.2 + lightningcss-linux-arm64-musl: 1.30.2 + lightningcss-linux-x64-gnu: 1.30.2 + lightningcss-linux-x64-musl: 1.30.2 + lightningcss-win32-arm64-msvc: 1.30.2 + lightningcss-win32-x64-msvc: 1.30.2 lines-and-columns@1.2.4: {} @@ -8195,15 +8952,15 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.5.1: {} + lru-cache@11.2.6: {} lru-cache@5.1.1: dependencies: yallist: 3.1.1 - lucide-react@0.574.0(react@19.2.7): + lucide-react@0.574.0(react@19.2.4): dependencies: - react: 19.2.7 + react: 19.2.4 magic-string@0.30.21: dependencies: @@ -8222,7 +8979,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.2 + picomatch: 2.3.1 mime-db@1.52.0: {} @@ -8240,35 +8997,43 @@ snapshots: mimic-function@5.0.1: {} - miniflare@4.20260611.0: + miniflare@4.20260217.0: dependencies: '@cspotcode/source-map-support': 0.8.1 sharp: 0.34.5 - undici: 7.24.8 - workerd: 1.20260611.1 - ws: 8.20.1 + undici: 7.18.2 + workerd: 1.20260217.0 + ws: 8.18.0 youch: 4.1.0-beta.10 transitivePeerDependencies: - bufferutil - utf-8-validate - minimatch@10.2.5: + minimatch@10.1.2: dependencies: - brace-expansion: 5.0.6 + '@isaacs/brace-expansion': 5.0.1 - minimatch@3.1.5: + minimatch@10.2.1: dependencies: - brace-expansion: 1.1.15 + brace-expansion: 5.0.2 - minimatch@8.0.7: + minimatch@3.1.2: dependencies: - brace-expansion: 2.1.1 + brace-expansion: 1.1.12 + + minimatch@8.0.4: + dependencies: + brace-expansion: 2.0.2 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 minimist@1.2.8: {} minipass@4.2.8: {} - minipass@7.1.3: {} + minipass@7.1.2: {} mkdirp@1.0.4: {} @@ -8276,40 +9041,40 @@ snapshots: dependencies: obliterator: 1.6.1 - motion-dom@12.40.0: + motion-dom@12.34.1: dependencies: - motion-utils: 12.39.0 + motion-utils: 12.29.2 - motion-utils@12.39.0: {} + motion-utils@12.29.2: {} - motion@12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + motion@12.34.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - framer-motion: 12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + framer-motion: 12.34.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tslib: 2.8.1 optionalDependencies: - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) ms@2.1.3: {} - msw@2.14.6(@types/node@25.9.3)(typescript@5.9.3): + msw@2.12.10(@types/node@25.2.3)(typescript@5.9.3): dependencies: - '@inquirer/confirm': 6.1.1(@types/node@25.9.3) - '@mswjs/interceptors': 0.41.9 - '@open-draft/deferred-promise': 3.0.0 + '@inquirer/confirm': 5.1.21(@types/node@25.2.3) + '@mswjs/interceptors': 0.41.3 + '@open-draft/deferred-promise': 2.2.0 '@types/statuses': 2.0.6 cookie: 1.1.1 - graphql: 16.14.2 - headers-polyfill: 5.0.1 + graphql: 16.12.0 + headers-polyfill: 4.0.3 is-node-process: 1.2.0 outvariant: 1.4.3 path-to-regexp: 6.3.0 picocolors: 1.1.1 - rettime: 0.11.11 + rettime: 0.10.1 statuses: 2.0.2 strict-event-emitter: 0.5.1 - tough-cookie: 6.0.1 - type-fest: 5.7.0 + tough-cookie: 6.0.0 + type-fest: 5.4.4 until-async: 3.0.2 yargs: 17.7.2 optionalDependencies: @@ -8317,9 +9082,9 @@ snapshots: transitivePeerDependencies: - '@types/node' - mute-stream@3.0.0: {} + mute-stream@2.0.0: {} - nanoid@3.3.12: {} + nanoid@3.3.11: {} napi-postinstall@0.3.4: {} @@ -8338,30 +9103,30 @@ snapshots: - '@cfworker/json-schema' - supports-color - next-themes@0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) - next@16.2.9(@babel/core@7.29.7)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + next@16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - '@next/env': 16.2.9 + '@next/env': 16.1.6 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.37 - caniuse-lite: 1.0.30001799 + baseline-browser-mapping: 2.9.19 + caniuse-lite: 1.0.30001769 postcss: 8.4.31 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.7) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.9 - '@next/swc-darwin-x64': 16.2.9 - '@next/swc-linux-arm64-gnu': 16.2.9 - '@next/swc-linux-arm64-musl': 16.2.9 - '@next/swc-linux-x64-gnu': 16.2.9 - '@next/swc-linux-x64-musl': 16.2.9 - '@next/swc-win32-arm64-msvc': 16.2.9 - '@next/swc-win32-x64-msvc': 16.2.9 + '@next/swc-darwin-arm64': 16.1.6 + '@next/swc-darwin-x64': 16.1.6 + '@next/swc-linux-arm64-gnu': 16.1.6 + '@next/swc-linux-arm64-musl': 16.1.6 + '@next/swc-linux-x64-gnu': 16.1.6 + '@next/swc-linux-x64-musl': 16.1.6 + '@next/swc-win32-arm64-msvc': 16.1.6 + '@next/swc-win32-x64-msvc': 16.1.6 babel-plugin-react-compiler: 1.0.0 sharp: 0.34.5 transitivePeerDependencies: @@ -8370,13 +9135,6 @@ snapshots: node-domexception@1.0.0: {} - node-exports-info@1.6.0: - dependencies: - array.prototype.flatmap: 1.3.3 - es-errors: 1.3.0 - object.entries: 1.1.9 - semver: 6.3.1 - node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 @@ -8387,7 +9145,7 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-releases@2.0.47: {} + node-releases@2.0.27: {} npm-run-path@4.0.1: dependencies: @@ -8408,39 +9166,39 @@ snapshots: object.assign@4.1.7: dependencies: - call-bind: 1.0.9 + call-bind: 1.0.8 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.1.2 + es-object-atoms: 1.1.1 has-symbols: 1.1.0 object-keys: 1.1.1 object.entries@1.1.9: dependencies: - call-bind: 1.0.9 + call-bind: 1.0.8 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.1.2 + es-object-atoms: 1.1.1 object.fromentries@2.0.8: dependencies: - call-bind: 1.0.9 + call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.2 - es-object-atoms: 1.1.2 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 object.groupby@1.0.3: dependencies: - call-bind: 1.0.9 + call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.2 + es-abstract: 1.24.1 object.values@1.2.1: dependencies: - call-bind: 1.0.9 + call-bind: 1.0.8 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.1.2 + es-object-atoms: 1.1.1 obliterator@1.6.1: {} @@ -8488,7 +9246,7 @@ snapshots: log-symbols: 6.0.0 stdin-discarder: 0.2.2 string-width: 7.2.0 - strip-ansi: 7.2.0 + strip-ansi: 7.1.2 outvariant@1.4.3: {} @@ -8516,7 +9274,7 @@ snapshots: parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.29.7 + '@babel/code-frame': 7.29.0 error-ex: 1.3.4 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 @@ -8529,8 +9287,6 @@ snapshots: path-exists@4.0.0: {} - path-expression-matcher@1.5.0: {} - path-key@3.1.1: {} path-key@4.0.0: {} @@ -8540,24 +9296,24 @@ snapshots: path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 - minipass: 7.1.3 + minipass: 7.1.2 - path-scurry@2.0.2: + path-scurry@2.0.1: dependencies: - lru-cache: 11.5.1 - minipass: 7.1.3 + lru-cache: 11.2.6 + minipass: 7.1.2 path-to-regexp@6.3.0: {} - path-to-regexp@8.4.2: {} + path-to-regexp@8.3.0: {} pathe@2.0.3: {} picocolors@1.1.1: {} - picomatch@2.3.2: {} + picomatch@2.3.1: {} - picomatch@4.0.4: {} + picomatch@4.0.3: {} pid-port@2.0.0: dependencies: @@ -8567,39 +9323,46 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-selector-parser@7.1.4: + postcss-selector-parser@6.0.10: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-selector-parser@7.1.1: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 postcss@8.4.31: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.15: + postcss@8.5.6: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 powershell-utils@0.1.0: {} + preact@10.28.3: {} + prelude-ls@1.2.1: {} - prettier-plugin-organize-imports@4.3.0(prettier@3.8.4)(typescript@5.9.3): + prettier-plugin-organize-imports@4.3.0(prettier@3.8.1)(typescript@5.9.3): dependencies: - prettier: 3.8.4 + prettier: 3.8.1 typescript: 5.9.3 - prettier-plugin-tailwindcss@0.7.4(prettier-plugin-organize-imports@4.3.0(prettier@3.8.4)(typescript@5.9.3))(prettier@3.8.4): + prettier-plugin-tailwindcss@0.7.2(prettier-plugin-organize-imports@4.3.0(prettier@3.8.1)(typescript@5.9.3))(prettier@3.8.1): dependencies: - prettier: 3.8.4 + prettier: 3.8.1 optionalDependencies: - prettier-plugin-organize-imports: 4.3.0(prettier@3.8.4)(typescript@5.9.3) + prettier-plugin-organize-imports: 4.3.0(prettier@3.8.1)(typescript@5.9.3) - prettier@3.8.4: {} + prettier@3.8.1: {} pretty-ms@9.3.0: dependencies: @@ -8623,9 +9386,9 @@ snapshots: punycode@2.3.1: {} - qs@6.15.2: + qs@6.14.1: dependencies: - side-channel: 1.1.1 + side-channel: 1.1.0 queue-microtask@1.2.3: {} @@ -8638,18 +9401,42 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 - react-dom@19.2.7(react@19.2.7): + react-dom@19.2.4(react@19.2.4): dependencies: - react: 19.2.7 + react: 19.2.4 scheduler: 0.27.0 - react-icons@5.6.0(react@19.2.7): + react-icons@5.5.0(react@19.2.4): dependencies: - react: 19.2.7 + react: 19.2.4 react-is@16.13.1: {} - react@19.2.7: {} + react-scan@0.5.3(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/types': 7.29.0 + '@preact/signals': 1.3.4(preact@10.28.3) + '@rollup/pluginutils': 5.3.0 + '@types/node': 20.19.33 + bippy: 0.5.30(@types/react@19.2.14)(react@19.2.4) + commander: 14.0.3 + esbuild: 0.25.12 + estree-walker: 3.0.3 + picocolors: 1.1.1 + preact: 10.28.3 + prompts: 2.4.2 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + unplugin: 2.1.0 + transitivePeerDependencies: + - '@types/react' + - rollup + - supports-color + + react@19.2.4: {} recast@0.23.11: dependencies: @@ -8661,18 +9448,18 @@ snapshots: reflect.getprototypeof@1.0.10: dependencies: - call-bind: 1.0.9 + call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.2 + es-abstract: 1.24.1 es-errors: 1.3.0 - es-object-atoms: 1.1.2 + es-object-atoms: 1.1.1 get-intrinsic: 1.3.0 get-proto: 1.0.1 which-builtin-type: 1.2.1 regexp.prototype.flags@1.5.4: dependencies: - call-bind: 1.0.9 + call-bind: 1.0.8 define-properties: 1.2.1 es-errors: 1.3.0 get-proto: 1.0.1 @@ -8683,18 +9470,21 @@ snapshots: require-from-string@2.0.2: {} - reselect@5.2.0: {} + reselect@5.1.1: {} resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} - resolve@2.0.0-next.7: + resolve@1.22.11: dependencies: - es-errors: 1.3.0 - is-core-module: 2.16.2 - node-exports-info: 1.6.0 - object-keys: 1.1.1 + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@2.0.0-next.5: + dependencies: + is-core-module: 2.16.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 @@ -8703,7 +9493,7 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 - rettime@0.11.11: {} + rettime@0.10.1: {} reusify@1.1.0: {} @@ -8713,7 +9503,7 @@ snapshots: depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 - path-to-regexp: 8.4.2 + path-to-regexp: 8.3.0 transitivePeerDependencies: - supports-color @@ -8723,9 +9513,9 @@ snapshots: dependencies: queue-microtask: 1.2.3 - safe-array-concat@1.1.4: + safe-array-concat@1.1.3: dependencies: - call-bind: 1.0.9 + call-bind: 1.0.8 call-bound: 1.0.4 get-intrinsic: 1.3.0 has-symbols: 1.1.0 @@ -8748,7 +9538,7 @@ snapshots: semver@6.3.1: {} - semver@7.8.4: {} + semver@7.7.4: {} send@1.2.1: dependencies: @@ -8775,8 +9565,6 @@ snapshots: transitivePeerDependencies: - supports-color - set-cookie-parser@3.1.0: {} - set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -8797,47 +9585,47 @@ snapshots: dependencies: dunder-proto: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.2 + es-object-atoms: 1.1.1 setprototypeof@1.2.0: {} - shadcn@3.8.5(@types/node@25.9.3)(typescript@5.9.3): + shadcn@3.8.5(@types/node@25.2.3)(typescript@5.9.3): dependencies: '@antfu/ni': 25.0.0 - '@babel/core': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) - '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) - '@dotenvx/dotenvx': 1.71.3 - '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@dotenvx/dotenvx': 1.52.0 + '@modelcontextprotocol/sdk': 1.26.0(zod@3.25.76) '@types/validate-npm-package-name': 4.0.2 - browserslist: 4.28.2 + browserslist: 4.28.1 commander: 14.0.3 - cosmiconfig: 9.0.2(typescript@5.9.3) - dedent: 1.7.2 + cosmiconfig: 9.0.0(typescript@5.9.3) + dedent: 1.7.1 deepmerge: 4.3.1 - diff: 8.0.4 + diff: 8.0.3 execa: 9.6.1 fast-glob: 3.3.3 - fs-extra: 11.3.5 + fs-extra: 11.3.3 fuzzysort: 3.1.0 https-proxy-agent: 7.0.6 kleur: 4.1.5 - msw: 2.14.6(@types/node@25.9.3)(typescript@5.9.3) + msw: 2.12.10(@types/node@25.2.3)(typescript@5.9.3) node-fetch: 3.3.2 open: 11.0.0 ora: 8.2.0 - postcss: 8.5.15 - postcss-selector-parser: 7.1.4 + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 prompts: 2.4.2 recast: 0.23.11 stringify-object: 5.0.0 - tailwind-merge: 3.6.0 + tailwind-merge: 3.4.1 ts-morph: 26.0.0 tsconfig-paths: 4.2.0 validate-npm-package-name: 7.0.2 zod: 3.25.76 - zod-to-json-schema: 3.25.2(zod@3.25.76) + zod-to-json-schema: 3.25.1(zod@3.25.76) transitivePeerDependencies: - '@cfworker/json-schema' - '@types/node' @@ -8847,9 +9635,9 @@ snapshots: sharp@0.34.5: dependencies: - '@img/colour': 1.1.0 + '@img/colour': 1.0.0 detect-libc: 2.1.2 - semver: 7.8.4 + semver: 7.7.4 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 @@ -8882,7 +9670,7 @@ snapshots: shebang-regex@3.0.0: {} - side-channel-list@1.0.1: + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 @@ -8902,11 +9690,11 @@ snapshots: object-inspect: 1.13.4 side-channel-map: 1.0.1 - side-channel@1.1.1: + side-channel@1.1.0: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 - side-channel-list: 1.0.1 + side-channel-list: 1.0.0 side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 @@ -8916,10 +9704,10 @@ snapshots: sisteransi@1.0.5: {} - sonner@2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) source-map-js@1.2.1: {} @@ -8954,59 +9742,58 @@ snapshots: string-width@7.2.0: dependencies: emoji-regex: 10.6.0 - get-east-asian-width: 1.6.0 - strip-ansi: 7.2.0 + get-east-asian-width: 1.4.0 + strip-ansi: 7.1.2 string.prototype.includes@2.0.1: dependencies: - call-bind: 1.0.9 + call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.24.2 + es-abstract: 1.24.1 string.prototype.matchall@4.0.12: dependencies: - call-bind: 1.0.9 + call-bind: 1.0.8 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.2 + es-abstract: 1.24.1 es-errors: 1.3.0 - es-object-atoms: 1.1.2 + es-object-atoms: 1.1.1 get-intrinsic: 1.3.0 gopd: 1.2.0 has-symbols: 1.1.0 internal-slot: 1.1.0 regexp.prototype.flags: 1.5.4 set-function-name: 2.0.2 - side-channel: 1.1.1 + side-channel: 1.1.0 string.prototype.repeat@1.0.0: dependencies: define-properties: 1.2.1 - es-abstract: 1.24.2 + es-abstract: 1.24.1 - string.prototype.trim@1.2.11: + string.prototype.trim@1.2.10: dependencies: - call-bind: 1.0.9 + call-bind: 1.0.8 call-bound: 1.0.4 define-data-property: 1.1.4 define-properties: 1.2.1 - es-abstract: 1.24.2 - es-object-atoms: 1.1.2 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 has-property-descriptors: 1.0.2 - safe-regex-test: 1.1.0 - string.prototype.trimend@1.0.10: + string.prototype.trimend@1.0.9: dependencies: - call-bind: 1.0.9 + call-bind: 1.0.8 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.1.2 + es-object-atoms: 1.1.1 string.prototype.trimstart@1.0.8: dependencies: - call-bind: 1.0.9 + call-bind: 1.0.8 define-properties: 1.2.1 - es-object-atoms: 1.1.2 + es-object-atoms: 1.1.1 stringify-object@5.0.0: dependencies: @@ -9018,7 +9805,7 @@ snapshots: dependencies: ansi-regex: 5.0.1 - strip-ansi@7.2.0: + strip-ansi@7.1.2: dependencies: ansi-regex: 6.2.2 @@ -9030,16 +9817,14 @@ snapshots: strip-json-comments@3.1.1: {} - strnum@2.4.0: - dependencies: - anynum: 1.0.0 + strnum@2.1.2: {} - styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.7): + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): dependencies: client-only: 0.0.1 - react: 19.2.7 + react: 19.2.4 optionalDependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.0 supports-color@10.2.2: {} @@ -9049,35 +9834,37 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + tabbable@6.4.0: {} + tagged-tag@1.0.0: {} - tailwind-merge@3.6.0: {} + tailwind-merge@3.4.1: {} - tailwindcss@4.3.1: {} + tailwindcss@4.1.18: {} - tapable@2.3.3: {} + tapable@2.3.0: {} terser@5.16.9: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.17.0 + acorn: 8.15.0 commander: 2.20.3 source-map-support: 0.5.21 tiny-invariant@1.3.3: {} - tinyexec@1.2.4: {} + tinyexec@1.0.2: {} - tinyglobby@0.2.17: + tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 - tldts-core@7.4.2: {} + tldts-core@7.0.23: {} - tldts@7.4.2: + tldts@7.0.23: dependencies: - tldts-core: 7.4.2 + tldts-core: 7.0.23 to-regex-range@5.0.1: dependencies: @@ -9085,19 +9872,19 @@ snapshots: toidentifier@1.0.1: {} - tough-cookie@6.0.1: + tough-cookie@6.0.0: dependencies: - tldts: 7.4.2 + tldts: 7.0.23 tr46@0.0.3: {} - ts-api-utils@2.5.0(typescript@5.9.3): + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 ts-declaration-location@1.0.7(typescript@5.9.3): dependencies: - picomatch: 4.0.4 + picomatch: 4.0.3 typescript: 5.9.3 ts-morph@26.0.0: @@ -9130,13 +9917,13 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-fest@5.7.0: + type-fest@5.4.4: dependencies: tagged-tag: 1.0.0 - type-is@2.1.0: + type-is@2.0.1: dependencies: - content-type: 2.0.0 + content-type: 1.0.5 media-typer: 1.1.0 mime-types: 3.0.2 @@ -9148,7 +9935,7 @@ snapshots: typed-array-byte-length@1.0.3: dependencies: - call-bind: 1.0.9 + call-bind: 1.0.8 for-each: 0.3.5 gopd: 1.2.0 has-proto: 1.2.0 @@ -9157,29 +9944,29 @@ snapshots: typed-array-byte-offset@1.0.4: dependencies: available-typed-arrays: 1.0.7 - call-bind: 1.0.9 + call-bind: 1.0.8 for-each: 0.3.5 gopd: 1.2.0 has-proto: 1.2.0 is-typed-array: 1.1.15 reflect.getprototypeof: 1.0.10 - typed-array-length@1.0.8: + typed-array-length@1.0.7: dependencies: - call-bind: 1.0.9 + call-bind: 1.0.8 for-each: 0.3.5 gopd: 1.2.0 is-typed-array: 1.1.15 possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): + typescript-eslint@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - eslint: 9.39.4(jiti@2.7.0) + '@typescript-eslint/eslint-plugin': 8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -9195,11 +9982,13 @@ snapshots: undici-types@5.26.5: {} - undici-types@7.24.6: {} + undici-types@6.21.0: {} + + undici-types@7.16.0: {} undici@7.16.0: {} - undici@7.24.8: {} + undici@7.18.2: {} unenv@2.0.0-rc.24: dependencies: @@ -9211,38 +10000,41 @@ snapshots: unpipe@1.0.0: {} - unrs-resolver@1.12.2: + unplugin@2.1.0: + dependencies: + acorn: 8.15.0 + webpack-virtual-modules: 0.6.2 + optional: true + + unrs-resolver@1.11.1: dependencies: napi-postinstall: 0.3.4 optionalDependencies: - '@unrs/resolver-binding-android-arm-eabi': 1.12.2 - '@unrs/resolver-binding-android-arm64': 1.12.2 - '@unrs/resolver-binding-darwin-arm64': 1.12.2 - '@unrs/resolver-binding-darwin-x64': 1.12.2 - '@unrs/resolver-binding-freebsd-x64': 1.12.2 - '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2 - '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2 - '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2 - '@unrs/resolver-binding-linux-arm64-musl': 1.12.2 - '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2 - '@unrs/resolver-binding-linux-loong64-musl': 1.12.2 - '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2 - '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2 - '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2 - '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2 - '@unrs/resolver-binding-linux-x64-gnu': 1.12.2 - '@unrs/resolver-binding-linux-x64-musl': 1.12.2 - '@unrs/resolver-binding-openharmony-arm64': 1.12.2 - '@unrs/resolver-binding-wasm32-wasi': 1.12.2 - '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2 - '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 - '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 + '@unrs/resolver-binding-android-arm-eabi': 1.11.1 + '@unrs/resolver-binding-android-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-x64': 1.11.1 + '@unrs/resolver-binding-freebsd-x64': 1.11.1 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 + '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-musl': 1.11.1 + '@unrs/resolver-binding-wasm32-wasi': 1.11.1 + '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 + '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 + '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 until-async@3.0.2: {} - update-browserslist-db@1.2.3(browserslist@4.28.2): + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: - browserslist: 4.28.2 + browserslist: 4.28.1 escalade: 3.2.0 picocolors: 1.1.1 @@ -9252,9 +10044,9 @@ snapshots: urlpattern-polyfill@10.1.0: {} - use-sync-external-store@1.6.0(react@19.2.7): + use-sync-external-store@1.6.0(react@19.2.4): dependencies: - react: 19.2.7 + react: 19.2.4 util-deprecate@1.0.2: {} @@ -9268,6 +10060,9 @@ snapshots: webidl-conversions@3.0.1: {} + webpack-virtual-modules@0.6.2: + optional: true + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -9284,7 +10079,7 @@ snapshots: which-builtin-type@1.2.1: dependencies: call-bound: 1.0.4 - function.prototype.name: 1.2.0 + function.prototype.name: 1.1.8 has-tostringtag: 1.0.2 is-async-function: 2.1.1 is-date-object: 1.1.0 @@ -9295,7 +10090,7 @@ snapshots: isarray: 2.0.5 which-boxed-primitive: 1.1.1 which-collection: 1.0.2 - which-typed-array: 1.1.22 + which-typed-array: 1.1.20 which-collection@1.0.2: dependencies: @@ -9304,10 +10099,10 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 - which-typed-array@1.1.22: + which-typed-array@1.1.20: dependencies: available-typed-arrays: 1.0.7 - call-bind: 1.0.9 + call-bind: 1.0.8 call-bound: 1.0.4 for-each: 0.3.5 get-proto: 1.0.1 @@ -9320,34 +10115,40 @@ snapshots: which@4.0.0: dependencies: - isexe: 3.1.5 + isexe: 3.1.4 word-wrap@1.2.5: {} - workerd@1.20260611.1: + workerd@1.20260217.0: optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20260611.1 - '@cloudflare/workerd-darwin-arm64': 1.20260611.1 - '@cloudflare/workerd-linux-64': 1.20260611.1 - '@cloudflare/workerd-linux-arm64': 1.20260611.1 - '@cloudflare/workerd-windows-64': 1.20260611.1 + '@cloudflare/workerd-darwin-64': 1.20260217.0 + '@cloudflare/workerd-darwin-arm64': 1.20260217.0 + '@cloudflare/workerd-linux-64': 1.20260217.0 + '@cloudflare/workerd-linux-arm64': 1.20260217.0 + '@cloudflare/workerd-windows-64': 1.20260217.0 - wrangler@4.100.0: + wrangler@4.66.0: dependencies: - '@cloudflare/kv-asset-handler': 0.5.0 - '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260611.1) + '@cloudflare/kv-asset-handler': 0.4.2 + '@cloudflare/unenv-preset': 2.13.0(unenv@2.0.0-rc.24)(workerd@1.20260217.0) blake3-wasm: 2.1.5 esbuild: 0.27.3 - miniflare: 4.20260611.0 + miniflare: 4.20260217.0 path-to-regexp: 6.3.0 unenv: 2.0.0-rc.24 - workerd: 1.20260611.1 + workerd: 1.20260217.0 optionalDependencies: fsevents: 2.3.3 transitivePeerDependencies: - bufferutil - utf-8-validate + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -9358,24 +10159,22 @@ snapshots: dependencies: ansi-styles: 6.2.3 string-width: 7.2.0 - strip-ansi: 7.2.0 + strip-ansi: 7.1.2 wrappy@1.0.2: {} - ws@8.20.1: {} + ws@8.18.0: {} wsl-utils@0.3.1: dependencies: is-wsl: 3.1.1 powershell-utils: 0.1.0 - xml-naming@0.1.0: {} - y18n@5.0.8: {} yallist@3.1.1: {} - yaml@2.9.0: {} + yaml@2.8.2: {} yargs-parser@21.1.1: {} @@ -9402,9 +10201,7 @@ snapshots: yocto-queue@0.1.0: {} - yocto-spinner@1.2.0: - dependencies: - yoctocolors: 2.1.2 + yoctocolors-cjs@2.1.3: {} yoctocolors@2.1.2: {} @@ -9417,18 +10214,18 @@ snapshots: dependencies: '@poppinss/colors': 4.1.6 '@poppinss/dumper': 0.6.5 - '@speed-highlight/core': 1.2.17 + '@speed-highlight/core': 1.2.14 cookie: 1.1.1 youch-core: 0.3.3 - zod-to-json-schema@3.25.2(zod@3.25.76): + zod-to-json-schema@3.25.1(zod@3.25.76): dependencies: zod: 3.25.76 - zod-validation-error@4.0.2(zod@4.4.3): + zod-validation-error@4.0.2(zod@4.3.6): dependencies: - zod: 4.4.3 + zod: 4.3.6 zod@3.25.76: {} - zod@4.4.3: {} + zod@4.3.6: {} diff --git a/web/public/_headers b/web/public/_headers index c846ea71..5392acf8 100644 --- a/web/public/_headers +++ b/web/public/_headers @@ -1,2 +1,8 @@ /_next/static/* Cache-Control: public, max-age=31536000, immutable + +/video.webm + Cache-Control: public, max-age=2592000 + +/logo.svg + Cache-Control: public, max-age=2592000 diff --git a/web/src/app/globals.css b/web/src/app/globals.css index e01ee0cb..63eec7d0 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -2,6 +2,8 @@ @import 'tw-animate-css'; @import 'shadcn/tailwind.css'; +@plugin '@tailwindcss/typography'; + @custom-variant dark (&:where(.dark, .dark *)); @theme inline { diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 28423320..52af8e85 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -161,13 +161,7 @@ function PhoneMedia({ className='size-full object-contain' /> ) : ( - {title} + {title} )} ) diff --git a/web/src/components/Providers.tsx b/web/src/components/Providers.tsx index 617d32c7..8f9612c6 100644 --- a/web/src/components/Providers.tsx +++ b/web/src/components/Providers.tsx @@ -3,12 +3,21 @@ import { Toaster } from '@/components/ui/sonner' import { MotionConfig } from 'motion/react' import { ThemeProvider } from 'next-themes' +import { useEffect } from 'react' interface ProvidersProps { children: React.ReactNode } export function Providers({ children }: ProvidersProps) { + useEffect(() => { + if (process.env.NODE_ENV === 'development') { + void import('react-scan').then(({ scan }) => { + scan({ enabled: true }) + }) + } + }, []) + return ( diff --git a/web/wrangler.jsonc b/web/wrangler.jsonc index 348d12cb..4dd24a14 100644 --- a/web/wrangler.jsonc +++ b/web/wrangler.jsonc @@ -29,6 +29,11 @@ // "bucket_name": "", // }, // ], + "images": { + // Enable image optimization + // see https://opennext.js.org/cloudflare/howtos/image + "binding": "IMAGES", + }, "minify": true, "keep_names": false, "upload_source_maps": true,