From f2b932bc93054e710d2ea3e3dbd44df735cd78a6 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sat, 13 Jun 2026 17:06:24 -0400 Subject: [PATCH] feat(video,chat): native player, ad-break awareness, chat send/delay reliability, and tab redesign (#567) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squash-merge commit. Covers four areas: native video player, chat reliability, multi-tab, and hardening. ## Native video player - Direct HLS playback via `better_native_video_player` (forked from `plug-and-pay/flutter-native-video-player`, patched on `frosty-patches` branch) - Twitch GQL playback token fetching with platform cookie extraction (iOS Swift + Android Kotlin plugins) for authenticated streams - Quality selection (source, 720p, 480p, 360p, 160p, audio-only) persisted per channel - Picture-in-picture: iOS automatic inline PiP, Android auto-PiP on API 31+ guarded against older devices - Latency polling, chat latency sync, stall detection/recovery, startup auto-pause recovery, watchdog timers - **Twitch stitched-ad break detection** (HLS `EXT-X-DATERANGE`) on both platforms, emitting `adBreakChanged` so the host suspends latency/stall recovery and chat sync during ads (program-date-time is unreliable then). Matches `CLASS="twitch-stitched-ad"` / `ID="stitched-ad-"` **and** the `X-TV-TWITCH-AD-*` attribute prefix (the durable signal streamlink/yt-dlp use) - Shared `VideoPlayerInterface` so WebView and native paths are interchangeable via settings toggle - `PlaybackAccessToken` model, `TwitchGqlApi` client ## Chat delay & send reliability - **Optimistic send-finalize**: Twitch acks a sent message with a per-PRIVMSG USERSTATE (not an echo), which it can intermittently drop — more so under a long chat delay. A missing ack no longer shows a false "message may not have been sent" error; the send is finalized optimistically (local echo rendered), and only genuine rejection NOTICEs surface an error. Ack timeout scaled by the chat delay. - **Per-line chat-delay bypass**: control/state lines (USERSTATE/ROOMSTATE/GLOBALUSERSTATE), rejection NOTICEs, the reconnect notice, and untagged server lines bypass the delay so send-acks and PONGs aren't stalled; only displayable chat content is delayed, staying in sync with delayed video. - **Measured chat bottom-bar height**: the message list reflows to the composer's real measured height, fixing the input overlapping messages when a long message expands it (replaces a fixed-constant estimate). - IRC tag parsing handles valueless and escaped tags per the IRCv3 message-tags spec (fixes a recent-messages RangeError on certain tags). ## Chat / multi-tab - Redesigned tab chips: unread dot indicator, chips dim until WebSocket connects - Animated tab options popover replacing `MenuAnchor` (no animation hook in Flutter 3.44) - Simplified `ChatTabs` from `StatefulWidget` to `StatelessWidget` - Focus-current-channel fade bump to 0.55; section-header alpha lifted to match the muted-label tier - `addTab` now calls `setActiveTab` to snapshot outgoing tab's `lastSeenMessageId` — fixes spurious unread dots after adding a tab - `onActivateSourceTab` threaded into `ChatUserModal` so Reply in merged mode switches to the correct source tab's composer ## Bug fixes and hardening - **Reverted the experimental iOS low-latency mode** (5s live-edge join + 1.05x catch-up): AVPlayer can't sustain <~18s on Twitch (declared `TARGETDURATION:6` paces playlist refreshes), and the offset starved the buffer into pause-on-launch. Default join distance restored. - Fork (Android): **cap `BEHIND_LIVE_WINDOW` recovery** so a stream repeatedly falling off the live window can't silently re-prepare forever; emits `error` on exhaustion so the host recovery ladder takes over - Guard all Android `setAutoPipMode` calls with `_autoPipAvailable` — prevents `PlatformException` on Android 8–11; check `_disposed` after `isAutoPipAvailable` await to prevent post-dispose auto-PiP re-enable - Remove `_isInPipMode` guard from `_onPaused` so `_paused` tracks actual state, fixing PiP play/pause toggle getting stuck - Tighten iOS cookie domain check to exact `twitch.tv` / `.twitch.tv` suffix - Invalidate the stored web session token when Twitch rejects it with a 401 (profile card flips back to unlinked; future loads skip the doomed authenticated request), and re-extract the cookie on launch when logged in without a linked session - Send `play_session_id` and `player_backend=mediaplayer` to Usher, matching the web client / streamlink - Fork (iOS): restore program-date-time latency measurement (a prior rev measured distance to the manifest edge, reading ~0s and silently disabling high-latency recovery / chat-delay auto-sync); release platform views on dispose (per-stream view + AVPlayerViewController leak), track the observed AVPlayerItem for KVO removal (sibling-view crash), main-thread seek results, resolve superseded load futures - Fork (Android): clean up method handlers orphaned by PiP-time view disposal (leaked audio-focus listeners), drop the live-offset re-anchoring seekTo on metadata updates (latency creep), restore pre-duck volume, respect user pause in the stall watchdog, resolve superseded load futures - Deep review pass: expire the quality-switch flag so stall recovery isn't disabled for the session, cap latency-driven recovery (clock-skew loop), generation-guard the error overlay, two-strike offline confirmation, controller null-safety on quality switch - Tighten `/me` local-echo match (no longer renders `/meow ...` as an action); clear pending send text on disconnect ## Web / docs - Remove stale Cloudflare Images binding from `wrangler.jsonc` (covered by `images.unoptimized: true`) - Correct `web/AGENTS.md` Cache Components claim (feature not enabled) - Repo and agent config cleanup ## Test plan - [x] `flutter analyze` — clean (frosty + plugin lib) - [x] `flutter test` — 319/319 pass (adds `ChatLatencySync`, `StreamInfoPoller`, video timing invariants, `PlaybackAccessToken`, IRC tag parsing) - [x] Android plugin Kotlin compiles against the bumped fork rev - [ ] Native player plays live streams on iOS and Android - [ ] Quality switching works and persists across channel reopens - [ ] PiP enters/exits correctly; pause/resume toggle in PiP window works - [ ] Audio-only mode does not crash on Android < API 31 - [ ] Sending under a long synced chat delay no longer shows a false "may not have been sent" error - [ ] Long composer text reflows the message list (no input/message overlap) - [ ] Multi-tab: adding a tab does not mark previous tab as unread - [ ] Merged chat: Reply from user modal targets the correct tab's composer 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .claude/settings.json | 31 - AGENTS.md | 38 +- .../tommychow/frosty/CookieExtractorPlugin.kt | 32 + .../com/tommychow/frosty/MainActivity.kt | 5 + android/gradle.properties | 12 +- ios/Podfile.lock | 287 -- ios/Runner.xcodeproj/project.pbxproj | 28 +- .../xcshareddata/swiftpm/Package.resolved | 122 + .../xcshareddata/xcschemes/Runner.xcscheme | 18 + .../xcshareddata/swiftpm/Package.resolved | 122 + ios/Runner/AppDelegate.swift | 7 + ios/Runner/CookieExtractorPlugin.swift | 27 + ios/Runner/Info.plist | 4 + lib/apis/twitch_gql_api.dart | 132 + lib/apis/unauthorized_interceptor.dart | 11 +- lib/constants.dart | 12 + lib/main.dart | 10 + lib/models/irc.dart | 21 +- lib/models/playback_access_token.dart | 65 + lib/screens/channel/channel.dart | 199 +- lib/screens/channel/chat/chat.dart | 7 + .../channel/chat/details/chat_modes.dart | 2 +- .../channel/chat/stores/chat_store.dart | 338 +- .../channel/chat/stores/chat_tabs_store.dart | 45 +- .../channel/chat/widgets/chat_bottom_bar.dart | 12 +- .../channel/chat/widgets/chat_message.dart | 49 +- .../channel/chat/widgets/chat_tabs.dart | 351 +- .../channel/chat/widgets/chat_user_modal.dart | 4 + .../channel/chat/widgets/reply_thread.dart | 2 +- .../channel/video/chat_latency_sync.dart | 32 + lib/screens/channel/video/native_video.dart | 106 + .../video/native_video_player_interface.dart | 12 + .../channel/video/native_video_store.dart | 1308 +++++++ .../channel/video/native_video_store.g.dart | 497 +++ .../channel/video/stream_info_poller.dart | 86 + lib/screens/channel/video/video_overlay.dart | 104 +- .../channel/video/video_player_interface.dart | 43 + lib/screens/channel/video/video_store.dart | 774 +++-- lib/screens/channel/video/video_store.g.dart | 20 + .../channel/video/video_timing_constants.dart | 67 + lib/screens/home/home.dart | 25 +- .../account/widgets/profile_card.dart | 60 +- lib/screens/settings/chat_settings.dart | 736 ++-- lib/screens/settings/general_settings.dart | 4 +- lib/screens/settings/stores/auth_store.dart | 41 +- lib/screens/settings/stores/auth_store.g.dart | 32 + .../settings/stores/settings_store.dart | 212 +- .../settings/stores/settings_store.g.dart | 534 +-- lib/screens/settings/video_settings.dart | 27 +- lib/services/cookie_extractor.dart | 9 + lib/theme.dart | 59 +- lib/utils/context_extensions.dart | 18 +- lib/widgets/expandable_section_header.dart | 12 +- lib/widgets/live_indicator.dart | 90 +- lib/widgets/measure_size.dart | 46 + lib/widgets/profile_picture.dart | 6 +- lib/widgets/section_header.dart | 10 +- lib/widgets/settings_page_layout.dart | 2 +- pubspec.lock | 3033 +++++++++-------- pubspec.yaml | 4 + test/models/playback_access_token_test.dart | 78 + .../channel/video/chat_latency_sync_test.dart | 111 + .../video/stream_info_poller_test.dart | 194 ++ .../video/video_timing_constants_test.dart | 159 + web/.gitignore | 1 + web/AGENTS.md | 47 +- web/package.json | 2 - web/pnpm-lock.yaml | 420 --- web/public/_headers | 6 - web/src/app/globals.css | 2 - web/src/components/Providers.tsx | 9 - web/wrangler.jsonc | 5 - 72 files changed, 7220 insertions(+), 3816 deletions(-) delete mode 100644 .claude/settings.json create mode 100644 android/app/src/main/kotlin/com/tommychow/frosty/CookieExtractorPlugin.kt create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 ios/Runner/CookieExtractorPlugin.swift create mode 100644 lib/apis/twitch_gql_api.dart create mode 100644 lib/models/playback_access_token.dart create mode 100644 lib/screens/channel/video/chat_latency_sync.dart create mode 100644 lib/screens/channel/video/native_video.dart create mode 100644 lib/screens/channel/video/native_video_player_interface.dart create mode 100644 lib/screens/channel/video/native_video_store.dart create mode 100644 lib/screens/channel/video/native_video_store.g.dart create mode 100644 lib/screens/channel/video/stream_info_poller.dart create mode 100644 lib/screens/channel/video/video_player_interface.dart create mode 100644 lib/screens/channel/video/video_timing_constants.dart create mode 100644 lib/services/cookie_extractor.dart create mode 100644 lib/widgets/measure_size.dart create mode 100644 test/models/playback_access_token_test.dart create mode 100644 test/screens/channel/video/chat_latency_sync_test.dart create mode 100644 test/screens/channel/video/stream_info_poller_test.dart create mode 100644 test/screens/channel/video/video_timing_constants_test.dart diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 260fee95..00000000 --- a/.claude/settings.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "enabledMcpjsonServers": ["next-devtools"], - "enabledPlugins": { - "frontend-design@claude-plugins-official": true, - "feature-dev@claude-plugins-official": true, - "commit-commands@claude-plugins-official": true, - "pr-review-toolkit@claude-plugins-official": true, - "typescript-lsp@claude-plugins-official": true, - "claude-md-management@claude-plugins-official": true, - "claude-code-setup@claude-plugins-official": true, - "skill-creator@claude-plugins-official": true - }, - "hooks": { - "PostToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "dart format \"$CLAUDE_FILE_PATH\" 2>/dev/null || true" - } - ] - } - ] - }, - "sandbox": { - "enabled": true, - "autoAllowBashIfSandboxed": true, - "enableWeakerNetworkIsolation": true - } -} diff --git a/AGENTS.md b/AGENTS.md index d9145970..3501b26c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,12 +1,29 @@ # AGENTS.md -> **Monorepo note:** `web/` is a separate Next.js landing page with its own `AGENTS.md`. This file covers the Flutter app only. +--- -## Workflow +# Author Preferences -- 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 +## Code Opinions + +- Package imports (`import 'package:frosty/...'`), not relative imports +- Trailing commas always +- Single quotes +- Extract related/grouped logic (state, reactions, actions) into dedicated stores when it improves readability — keep widgets focused on rendering + +## Infrastructure Checklist + +When creating new infrastructure (screens, API clients, stores), use exploration findings as a **checklist** — systematically verify each convention is followed before writing code. + +## Never + +- Never edit `.g.dart` files directly — always regenerate with `dart run build_runner build` + +--- + +# Project + +Flutter mobile app for browsing Twitch on iOS and Android. Uses MobX for state management with code generation. ## Commands @@ -29,7 +46,7 @@ dart run build_runner build --delete-conflicting-outputs # Same, but - `lib/widgets/` — Shared widgets - `lib/utils/` — Utility functions -## Architecture (non-obvious bits) +## Architecture - 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 @@ -39,9 +56,6 @@ dart run build_runner build --delete-conflicting-outputs # Same, but - 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 ## Testing @@ -49,9 +63,3 @@ dart run build_runner build --delete-conflicting-outputs # Same, but - HTTP mocking: `http_mock_adapter` (`DioAdapter`) — use full URLs in `onGet`/`onPost` - General mocking: `mocktail` (no codegen required) - Fixtures live in `test/fixtures/` (e.g., `irc_messages.dart`, `api_responses.dart`) - -## Commits - -Conventional commits: `type(scope): description` — lowercase, no trailing period, tightly scoped (e.g., `fix(chat): correct landscape bottom padding in bottom bar`). Append `!` before the `:` for breaking changes (e.g., `feat(api)!: drop legacy auth flow`). - -Common types: `feat`, `fix`, `chore`, `refactor`, `docs`, `test`, `perf`, `style`. Scope is the affected area (e.g., `chat`, `video`, `api`). diff --git a/android/app/src/main/kotlin/com/tommychow/frosty/CookieExtractorPlugin.kt b/android/app/src/main/kotlin/com/tommychow/frosty/CookieExtractorPlugin.kt new file mode 100644 index 00000000..055101d0 --- /dev/null +++ b/android/app/src/main/kotlin/com/tommychow/frosty/CookieExtractorPlugin.kt @@ -0,0 +1,32 @@ +package com.tommychow.frosty + +import android.webkit.CookieManager +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +class CookieExtractorPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { + private lateinit var channel: MethodChannel + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel(binding.binaryMessenger, "frosty/cookie_extractor") + channel.setMethodCallHandler(this) + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + if (call.method == "extractTwitchAuthToken") { + val cookies = CookieManager.getInstance().getCookie("https://twitch.tv") + val authToken = cookies?.split(";") + ?.map { it.trim() } + ?.firstOrNull { it.startsWith("auth-token=") } + ?.substringAfter("auth-token=") + result.success(authToken) + } else { + result.notImplemented() + } + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + } +} diff --git a/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt b/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt index a7791025..30e59dc7 100644 --- a/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt +++ b/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt @@ -1,6 +1,11 @@ package com.tommychow.frosty import cl.puntito.simple_pip_mode.PipCallbackHelperActivityWrapper +import io.flutter.embedding.engine.FlutterEngine class MainActivity: PipCallbackHelperActivityWrapper() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + flutterEngine.plugins.add(CookieExtractorPlugin()) + } } diff --git a/android/gradle.properties b/android/gradle.properties index 01a11bb7..6fdfce35 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,8 @@ -org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError -android.useAndroidX=true -org.gradle.caching=true -org.gradle.parallel=true +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 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index a571234b..6cba02bd 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,314 +1,27 @@ 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.9.0): - - FirebaseCore (~> 12.9.0) - - Firebase/Crashlytics (12.9.0): - - Firebase/CoreOnly - - FirebaseCrashlytics (~> 12.9.0) - - Firebase/Performance (12.9.0): - - Firebase/CoreOnly - - FirebasePerformance (~> 12.9.0) - - firebase_analytics (12.1.3): - - firebase_core - - FirebaseAnalytics (= 12.9.0) - - Flutter - - firebase_core (4.5.0): - - Firebase/CoreOnly (= 12.9.0) - - Flutter - - firebase_crashlytics (5.0.8): - - Firebase/Crashlytics (= 12.9.0) - - firebase_core - - Flutter - - firebase_performance (0.11.1-5): - - Firebase/Performance (= 12.9.0) - - firebase_core - - Flutter - - FirebaseABTesting (12.9.0): - - FirebaseCore (~> 12.9.0) - - FirebaseAnalytics (12.9.0): - - FirebaseAnalytics/Default (= 12.9.0) - - FirebaseCore (~> 12.9.0) - - FirebaseInstallations (~> 12.9.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - - GoogleUtilities/MethodSwizzler (~> 8.1) - - GoogleUtilities/Network (~> 8.1) - - "GoogleUtilities/NSData+zlib (~> 8.1)" - - nanopb (~> 3.30910.0) - - FirebaseAnalytics/Default (12.9.0): - - FirebaseCore (~> 12.9.0) - - FirebaseInstallations (~> 12.9.0) - - GoogleAppMeasurement/Default (= 12.9.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - - GoogleUtilities/MethodSwizzler (~> 8.1) - - GoogleUtilities/Network (~> 8.1) - - "GoogleUtilities/NSData+zlib (~> 8.1)" - - nanopb (~> 3.30910.0) - - FirebaseCore (12.9.0): - - FirebaseCoreInternal (~> 12.9.0) - - GoogleUtilities/Environment (~> 8.1) - - GoogleUtilities/Logger (~> 8.1) - - FirebaseCoreExtension (12.9.0): - - FirebaseCore (~> 12.9.0) - - FirebaseCoreInternal (12.9.0): - - "GoogleUtilities/NSData+zlib (~> 8.1)" - - FirebaseCrashlytics (12.9.0): - - FirebaseCore (~> 12.9.0) - - FirebaseInstallations (~> 12.9.0) - - FirebaseRemoteConfigInterop (~> 12.9.0) - - FirebaseSessions (~> 12.9.0) - - GoogleDataTransport (~> 10.1) - - GoogleUtilities/Environment (~> 8.1) - - nanopb (~> 3.30910.0) - - PromisesObjC (~> 2.4) - - FirebaseInstallations (12.9.0): - - FirebaseCore (~> 12.9.0) - - GoogleUtilities/Environment (~> 8.1) - - GoogleUtilities/UserDefaults (~> 8.1) - - PromisesObjC (~> 2.4) - - FirebasePerformance (12.9.0): - - FirebaseCore (~> 12.9.0) - - FirebaseInstallations (~> 12.9.0) - - FirebaseRemoteConfig (~> 12.9.0) - - FirebaseSessions (~> 12.9.0) - - GoogleDataTransport (~> 10.1) - - GoogleUtilities/Environment (~> 8.1) - - GoogleUtilities/MethodSwizzler (~> 8.1) - - GoogleUtilities/UserDefaults (~> 8.1) - - nanopb (~> 3.30910.0) - - FirebaseRemoteConfig (12.9.0): - - FirebaseABTesting (~> 12.9.0) - - FirebaseCore (~> 12.9.0) - - FirebaseInstallations (~> 12.9.0) - - FirebaseRemoteConfigInterop (~> 12.9.0) - - FirebaseSharedSwift (~> 12.9.0) - - GoogleUtilities/Environment (~> 8.1) - - "GoogleUtilities/NSData+zlib (~> 8.1)" - - FirebaseRemoteConfigInterop (12.9.0) - - FirebaseSessions (12.9.0): - - FirebaseCore (~> 12.9.0) - - FirebaseCoreExtension (~> 12.9.0) - - FirebaseInstallations (~> 12.9.0) - - GoogleDataTransport (~> 10.1) - - GoogleUtilities/Environment (~> 8.1) - - GoogleUtilities/UserDefaults (~> 8.1) - - nanopb (~> 3.30910.0) - - PromisesSwift (~> 2.1) - - FirebaseSharedSwift (12.9.0) - Flutter (1.0.0) - - flutter_native_splash (2.4.3): - - Flutter - flutter_secure_storage (6.0.0): - Flutter - - GoogleAdsOnDeviceConversion (3.2.0): - - GoogleUtilities/Environment (~> 8.1) - - GoogleUtilities/Logger (~> 8.1) - - GoogleUtilities/Network (~> 8.1) - - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/Core (12.9.0): - - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - - GoogleUtilities/MethodSwizzler (~> 8.1) - - GoogleUtilities/Network (~> 8.1) - - "GoogleUtilities/NSData+zlib (~> 8.1)" - - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/Default (12.9.0): - - GoogleAdsOnDeviceConversion (~> 3.2.0) - - GoogleAppMeasurement/Core (= 12.9.0) - - GoogleAppMeasurement/IdentitySupport (= 12.9.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - - GoogleUtilities/MethodSwizzler (~> 8.1) - - GoogleUtilities/Network (~> 8.1) - - "GoogleUtilities/NSData+zlib (~> 8.1)" - - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/IdentitySupport (12.9.0): - - GoogleAppMeasurement/Core (= 12.9.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - - GoogleUtilities/MethodSwizzler (~> 8.1) - - GoogleUtilities/Network (~> 8.1) - - "GoogleUtilities/NSData+zlib (~> 8.1)" - - nanopb (~> 3.30910.0) - - GoogleDataTransport (10.1.0): - - nanopb (~> 3.30910.0) - - PromisesObjC (~> 2.4) - - GoogleUtilities/AppDelegateSwizzler (8.1.0): - - GoogleUtilities/Environment - - GoogleUtilities/Logger - - GoogleUtilities/Network - - GoogleUtilities/Privacy - - GoogleUtilities/Environment (8.1.0): - - GoogleUtilities/Privacy - - GoogleUtilities/Logger (8.1.0): - - GoogleUtilities/Environment - - GoogleUtilities/Privacy - - GoogleUtilities/MethodSwizzler (8.1.0): - - GoogleUtilities/Logger - - GoogleUtilities/Privacy - - GoogleUtilities/Network (8.1.0): - - GoogleUtilities/Logger - - "GoogleUtilities/NSData+zlib" - - GoogleUtilities/Privacy - - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (8.1.0)": - - GoogleUtilities/Privacy - - GoogleUtilities/Privacy (8.1.0) - - GoogleUtilities/Reachability (8.1.0): - - GoogleUtilities/Logger - - GoogleUtilities/Privacy - - GoogleUtilities/UserDefaults (8.1.0): - - GoogleUtilities/Logger - - GoogleUtilities/Privacy - - in_app_review (2.0.0): - - Flutter - - nanopb (3.30910.0): - - nanopb/decode (= 3.30910.0) - - nanopb/encode (= 3.30910.0) - - nanopb/decode (3.30910.0) - - nanopb/encode (3.30910.0) - - native_device_orientation (0.0.1): - - Flutter - - package_info_plus (0.4.5): - - Flutter - - PromisesObjC (2.4.0) - - PromisesSwift (2.4.0): - - PromisesObjC (= 2.4.0) - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS - - sqflite_darwin (0.0.4): - - Flutter - - FlutterMacOS - - url_launcher_ios (0.0.1): - - Flutter - - wakelock_plus (0.0.1): - - Flutter - - webview_flutter_wkwebview (0.0.1): - - Flutter - - FlutterMacOS DEPENDENCIES: - advanced_in_app_review (from `.symlinks/plugins/advanced_in_app_review/ios`) - - app_links (from `.symlinks/plugins/app_links/ios`) - - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) - - firebase_core (from `.symlinks/plugins/firebase_core/ios`) - - firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`) - - firebase_performance (from `.symlinks/plugins/firebase_performance/ios`) - Flutter (from `Flutter`) - - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - - in_app_review (from `.symlinks/plugins/in_app_review/ios`) - - native_device_orientation (from `.symlinks/plugins/native_device_orientation/ios`) - - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) - - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`) - -SPEC REPOS: - trunk: - - Firebase - - FirebaseABTesting - - FirebaseAnalytics - - FirebaseCore - - FirebaseCoreExtension - - FirebaseCoreInternal - - FirebaseCrashlytics - - FirebaseInstallations - - FirebasePerformance - - FirebaseRemoteConfig - - FirebaseRemoteConfigInterop - - FirebaseSessions - - FirebaseSharedSwift - - GoogleAdsOnDeviceConversion - - GoogleAppMeasurement - - GoogleDataTransport - - GoogleUtilities - - nanopb - - PromisesObjC - - PromisesSwift EXTERNAL SOURCES: advanced_in_app_review: :path: ".symlinks/plugins/advanced_in_app_review/ios" - app_links: - :path: ".symlinks/plugins/app_links/ios" - device_info_plus: - :path: ".symlinks/plugins/device_info_plus/ios" - firebase_analytics: - :path: ".symlinks/plugins/firebase_analytics/ios" - firebase_core: - :path: ".symlinks/plugins/firebase_core/ios" - firebase_crashlytics: - :path: ".symlinks/plugins/firebase_crashlytics/ios" - firebase_performance: - :path: ".symlinks/plugins/firebase_performance/ios" Flutter: :path: Flutter - flutter_native_splash: - :path: ".symlinks/plugins/flutter_native_splash/ios" flutter_secure_storage: :path: ".symlinks/plugins/flutter_secure_storage/ios" - in_app_review: - :path: ".symlinks/plugins/in_app_review/ios" - native_device_orientation: - :path: ".symlinks/plugins/native_device_orientation/ios" - package_info_plus: - :path: ".symlinks/plugins/package_info_plus/ios" - shared_preferences_foundation: - :path: ".symlinks/plugins/shared_preferences_foundation/darwin" - sqflite_darwin: - :path: ".symlinks/plugins/sqflite_darwin/darwin" - url_launcher_ios: - :path: ".symlinks/plugins/url_launcher_ios/ios" - wakelock_plus: - :path: ".symlinks/plugins/wakelock_plus/ios" - webview_flutter_wkwebview: - :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" SPEC CHECKSUMS: advanced_in_app_review: fe8fd50b8670cb3dbe67e4c26ca643f7aea8dbb8 - app_links: 6d01271b3907b0ee7325c5297c75d697c4226c4d - device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 - Firebase: 065f2bb395062046623036d8e6dc857bc2521d56 - firebase_analytics: b5c909bdc321fab0f9ddf8712f5cef79057e09fa - firebase_core: 8f0a8c3aca22e29012e419ee18109b4aa021f777 - firebase_crashlytics: 4b21fc111f15e814eeab83f90a71efe3c1e9399a - firebase_performance: 0a30e8afaae656fdf6fa271ac3c62dc26b74d7a1 - FirebaseABTesting: a399ffe546392a39b19a5c2fb28bd8ea178a6f47 - FirebaseAnalytics: cd7d01d352f3c237c9a0e31552c257cd0b0c0352 - FirebaseCore: 428912f751178b06bef0a1793effeb4a5e09a9b8 - FirebaseCoreExtension: e911052d59cd0da237a45d706fc0f81654f035c1 - FirebaseCoreInternal: b321eafae5362113bc182956fafc9922cfc77b72 - FirebaseCrashlytics: 43913d587ef07beaf5db703baa61eacf9554658c - FirebaseInstallations: 7b64ffd006032b2b019a59b803858df5112d9eaa - FirebasePerformance: 94f614453614d8bb2a1a0177f3a1a6d2dbf4c504 - FirebaseRemoteConfig: a2f6545e41551ffb520241d38b5d3d6776c9ebe8 - FirebaseRemoteConfigInterop: 765ee19cd2bfa8e54937c8dae901eb634ad6787d - FirebaseSessions: a2d06fd980431fda934c7a543901aca05fc4edcc - FirebaseSharedSwift: 9d2fa84a46676302b89dbd5e6e62bce2fe376909 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29 flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 - GoogleAdsOnDeviceConversion: d68c69dd9581a0f5da02617b6f377e5be483970f - GoogleAppMeasurement: fce7c1c90640d2f9f5c56771f71deacb2ba3f98c - GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 - GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 - in_app_review: 436034b18594851a7328d7f1c2ed5ec235b79cfc - nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 - native_device_orientation: 42487b19c552fb5a6a2b51e61b93366f628113df - package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 - PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 - PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 - shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 - sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d - url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa - wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e - webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4 PODFILE CHECKSUM: 57c8aed26fba39d3ec9424816221f294a07c58eb diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index e82e9a80..c83d966a 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -11,11 +11,13 @@ 3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E751BC99755AAC391DC0B29F /* Pods_Runner.framework */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + F1C00CE01A2B3C4D00E5F6A8 /* CookieExtractorPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; BE8095795EAFF58C08A43B48 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A68609F67C30CFEF267CD4D3 /* GoogleService-Info.plist */; }; D4F5A60712340001ABCD1234 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -38,6 +40,7 @@ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieExtractorPlugin.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 95D7E205EE04D01F6996ECFC /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; @@ -51,6 +54,7 @@ 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 */ @@ -58,6 +62,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, 3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -68,6 +73,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -106,6 +112,7 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */, ); @@ -134,6 +141,9 @@ /* Begin PBXNativeTarget section */ 97C146ED1CF9000F007C117D /* Runner */ = { + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( @@ -160,6 +170,9 @@ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); isa = PBXProject; attributes = { LastUpgradeCheck = 1510; @@ -246,7 +259,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "# 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 = "# Symbol upload only matters for builds that ship; Debug dSYMs are absent or empty and fail the tool.\nif [ \"$CONFIGURATION\" = \"Debug\" ]; then\n echo \"Skipping Crashlytics symbol upload for Debug build\"\n exit 0\nfi\n# Locate upload-symbols whether Firebase is integrated via CocoaPods or Swift Package Manager.\nBIN=\"$PODS_ROOT/FirebaseCrashlytics/upload-symbols\"\n[ -f \"$BIN\" ] || BIN=\"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/upload-symbols\"\n\"$BIN\" --flutter-project \"$PROJECT_DIR/firebase_app_id_file.json\"\n"; }; 34CC65D58A1948B306772884 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; @@ -304,6 +317,7 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + F1C00CE01A2B3C4D00E5F6A8 /* CookieExtractorPlugin.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -590,6 +604,18 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..7baba9ed --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,122 @@ +{ + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", + "version" : "11.2.0" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "8d5b4189f1f482df8d5c58c9985ea70491ef5382", + "version" : "12.14.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" : "219e564a8510e983e675c94f77f7f7c50049f22d", + "version" : "12.14.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" : "60da361632d0de02786f709bdc0c4df340f7613e", + "version" : "8.1.0" + } + }, + { + "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 9c12df59..5db441f5 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + Bool { GeneratedPluginRegistrant.register(with: self) + guard let cookieExtractorRegistrar = self.registrar( + forPlugin: "CookieExtractorPlugin" + ) else { + assertionFailure("Failed to create CookieExtractorPlugin registrar") + return false + } + CookieExtractorPlugin.register(with: cookieExtractorRegistrar) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } } diff --git a/ios/Runner/CookieExtractorPlugin.swift b/ios/Runner/CookieExtractorPlugin.swift new file mode 100644 index 00000000..24495865 --- /dev/null +++ b/ios/Runner/CookieExtractorPlugin.swift @@ -0,0 +1,27 @@ +import Flutter +import WebKit + +class CookieExtractorPlugin: NSObject, FlutterPlugin { + static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "frosty/cookie_extractor", + binaryMessenger: registrar.messenger() + ) + let instance = CookieExtractorPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + if call.method == "extractTwitchAuthToken" { + WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in + let authCookie = cookies.first { + $0.name == "auth-token" && + ($0.domain == "twitch.tv" || $0.domain.hasSuffix(".twitch.tv")) + } + result(authCookie?.value) + } + } else { + result(FlutterMethodNotImplemented) + } + } +} diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 8aa7eded..6052fdf9 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -54,5 +54,9 @@ UIStatusBarHidden + UIBackgroundModes + + audio + diff --git a/lib/apis/twitch_gql_api.dart b/lib/apis/twitch_gql_api.dart new file mode 100644 index 00000000..9d6a5605 --- /dev/null +++ b/lib/apis/twitch_gql_api.dart @@ -0,0 +1,132 @@ +import 'dart:math'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/constants.dart'; +import 'package:frosty/models/playback_access_token.dart'; + +/// Twitch GQL API client for fetching playback access tokens. +/// +/// 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'); + + /// 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. + /// + /// 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, + }) 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': 'site', + 'platform': 'web', + }; + + 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); + } + + /// 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(); + } +} diff --git a/lib/apis/unauthorized_interceptor.dart b/lib/apis/unauthorized_interceptor.dart index 5f3f26cc..bd983fc4 100644 --- a/lib/apis/unauthorized_interceptor.dart +++ b/lib/apis/unauthorized_interceptor.dart @@ -14,8 +14,9 @@ class UnauthorizedInterceptor extends Interceptor { @override void onError(DioException err, ErrorInterceptorHandler handler) { - // Check if this is a 401 Unauthorized error - if (err.response?.statusCode == 401) { + // Check if this is a 401 Unauthorized error on a Twitch Helix/OAuth request + if (err.response?.statusCode == 401 && + _isTwitchHelixOrOAuth(err.requestOptions.uri)) { // For token validation requests, let the error propagate so validateToken can handle it if (err.requestOptions.uri.path.endsWith('/validate')) { handler.next(err); @@ -89,4 +90,10 @@ class UnauthorizedInterceptor extends Interceptor { } 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'); + } } diff --git a/lib/constants.dart b/lib/constants.dart index 2edaddba..0b977e2a 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -3,6 +3,18 @@ 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'); diff --git a/lib/main.dart b/lib/main.dart index cc428c84..6941eca0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,8 +17,10 @@ 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'; @@ -52,6 +54,12 @@ void main() async { 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. @@ -104,6 +112,7 @@ 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( @@ -137,6 +146,7 @@ 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), diff --git a/lib/models/irc.dart b/lib/models/irc.dart index c2bea5d2..4f49f28c 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -176,7 +176,9 @@ class IRCMessage { size: badgeSize, color: Theme.of(context).iconTheme.color?.withValues(alpha: 0.5) ?? - Colors.grey.withValues(alpha: 0.5), + Theme.of(context).colorScheme.onSurfaceVariant.withValues( + alpha: 0.5, + ), ), ), ), @@ -661,6 +663,8 @@ class IRCMessage { bool showMessage = true, Map? channelIdToUserTwitch, TimestampType timestamp = TimestampType.disabled, + bool forceTimestamp = false, + bool showHistoricalTimestamps = false, String? currentChannelId, }) { final emoteToObject = assetsStore.emoteToObject; @@ -671,7 +675,19 @@ class IRCMessage { // The span list that will be used to render the chat message final span = []; - _addTimestamp(span, style, timestamp); + // When the user has timestamps disabled globally, fall back to the device's + // locale preference for historical messages (opt-in via setting) and for + // forced contexts like previews. + final isHistorical = tags['historical'] == '1'; + final effectiveTimestamp = + (timestamp == TimestampType.disabled && + ((isHistorical && showHistoricalTimestamps) || forceTimestamp)) + ? (MediaQuery.alwaysUse24HourFormatOf(context) + ? TimestampType.twentyFour + : TimestampType.twelve) + : timestamp; + + _addTimestamp(span, style, effectiveTimestamp); _addHistoricalAndChannelBadges( context, span, @@ -680,7 +696,6 @@ class IRCMessage { currentChannelId, ); - final isHistorical = tags['historical'] == '1'; _addUserBadges( context, span, diff --git a/lib/models/playback_access_token.dart b/lib/models/playback_access_token.dart new file mode 100644 index 00000000..8ca16364 --- /dev/null +++ b/lib/models/playback_access_token.dart @@ -0,0 +1,65 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +class PlaybackAccessToken { + final String value; + final String signature; + + /// Whether Twitch will suppress ads for this session (Turbo, sub, etc.). + /// Defaults to false when the token payload can't be parsed. + final bool hideAds; + + /// Quality group names (e.g. "1080p60") that exist for this stream but are + /// restricted to subscribers, so they won't appear in the HLS playlist. + final List restrictedQualities; + + const PlaybackAccessToken({ + required this.value, + required this.signature, + this.hideAds = false, + this.restrictedQualities = const [], + }); + + /// Names in restricted_bitrates that aren't real video qualities + /// (matches streamlink's filter). + static final _nonQualityNameRe = RegExp(r'^((.+_)?archives|live|chunked)'); + + factory PlaybackAccessToken.fromGqlResponse(Map json) { + final data = + (json['data'] as Map?)?['streamPlaybackAccessToken'] + as Map?; + if (data == null) { + throw const FormatException( + 'streamPlaybackAccessToken missing from GQL response', + ); + } + + final value = data['value'] as String; + var hideAds = false; + var restricted = const []; + try { + final payload = jsonDecode(value) as Map; + hideAds = payload['hide_ads'] as bool? ?? false; + final bitrates = + (payload['chansub'] as Map?)?['restricted_bitrates'] + as List?; + if (bitrates != null) { + restricted = bitrates + .whereType() + .where((name) => !_nonQualityNameRe.hasMatch(name)) + .toList(); + } + } catch (e) { + // Token payload format is Twitch-internal — never fail playback over it. + debugPrint('PlaybackAccessToken: failed to parse token payload: $e'); + } + + return PlaybackAccessToken( + value: value, + signature: data['signature'] as String, + hideAds: hideAds, + restrictedQualities: restricted, + ); + } +} diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index 3d24bb26..d1469988 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -6,9 +6,13 @@ 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/native_video.dart'; +import 'package:frosty/screens/channel/video/native_video_player_interface.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'; @@ -43,11 +47,10 @@ class _VideoChatState extends State final _videoKey = GlobalKey(); final _chatKey = GlobalKey(); - // PiP drag state - essential only - double _pipDragDistance = 0; + // PiP drag state — ValueNotifier avoids full widget rebuilds during drag. + final _pipDragDistance = ValueNotifier(0); bool _isPipDragging = false; - bool _isInPipTriggerZone = - false; // Track when in trigger zone for haptic feedback + bool _isInPipTriggerZone = false; // Divider drag state for synchronizing animation bool _isDividerDragging = false; @@ -75,13 +78,24 @@ class _VideoChatState extends State primaryDisplayName: widget.userName, ); - late final VideoStore _videoStore = VideoStore( - userLogin: widget.userLogin, - userId: widget.userId, - twitchApi: context.twitchApi, - authStore: context.authStore, - settingsStore: context.settingsStore, - ); + late final VideoPlayerInterface _videoStore = + context.settingsStore.useNativePlayer + ? NativeVideoStore( + userLogin: widget.userLogin, + userId: widget.userId, + displayName: widget.userName, + twitchApi: context.twitchApi, + twitchGqlApi: context.twitchGqlApi, + authStore: context.authStore, + settingsStore: context.settingsStore, + ) + : VideoStore( + userLogin: widget.userLogin, + userId: widget.userId, + twitchApi: context.twitchApi, + authStore: context.authStore, + settingsStore: context.settingsStore, + ); @override void initState() { @@ -98,12 +112,10 @@ class _VideoChatState extends State CurvedAnimation(parent: _animationController, curve: Curves.easeOut), ); - // Attach listener to the controller (not the derived animation) so it - // survives _springBackAnimation being reassigned in _animateSpringBack. + // Drive the ValueNotifier from the spring-back animation so the + // AnimatedBuilder rebuilds without a full setState. _animationController.addListener(() { - setState(() { - _pipDragDistance = _springBackAnimation.value; - }); + _pipDragDistance.value = _springBackAnimation.value; }); // Register as observer for app lifecycle events @@ -120,58 +132,58 @@ class _VideoChatState extends State } void _handlePipDragStart(DragStartDetails details) { - // Disable drag gesture when already in PiP mode or video is not playing - if (_videoStore.isInPipMode || _videoStore.paused) return; + if (_videoStore.isInPipMode || _videoStore.paused || _videoStore.loading) { + return; + } - _animationController.stop(); // Stop any ongoing animation + _animationController.stop(); + _pipDragDistance.value = 0; + _isInPipTriggerZone = false; setState(() { _isPipDragging = true; - _pipDragDistance = 0; - _isInPipTriggerZone = false; }); } void _handlePipDragUpdate(DragUpdateDetails details) { - if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) { + if (!_isPipDragging || + _videoStore.isInPipMode || + _videoStore.paused || + _videoStore.loading) { return; } - setState(() { - _pipDragDistance += details.delta.dy; - _pipDragDistance = _pipDragDistance.clamp(0, _pipMaxDragDistance); + // Update the ValueNotifier directly — only the ListenableBuilder rebuilds, + // not the entire channel scaffold. + _pipDragDistance.value = + (_pipDragDistance.value + details.delta.dy).clamp(0, _pipMaxDragDistance); - // Check if we've entered or exited the trigger zone for haptic feedback - final wasInTriggerZone = _isInPipTriggerZone; - _isInPipTriggerZone = _pipDragDistance >= _pipTriggerDistance; + final wasInTriggerZone = _isInPipTriggerZone; + _isInPipTriggerZone = _pipDragDistance.value >= _pipTriggerDistance; - // 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 - } - }); + if (!wasInTriggerZone && _isInPipTriggerZone) { + HapticFeedback.mediumImpact(); + } else if (wasInTriggerZone && !_isInPipTriggerZone) { + HapticFeedback.lightImpact(); + } } void _handlePipDragEnd(DragEndDetails details) { - if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) { + if (!_isPipDragging || + _videoStore.isInPipMode || + _videoStore.paused || + _videoStore.loading) { return; } final velocity = details.velocity.pixelsPerSecond.dy; final shouldTriggerPip = - _pipDragDistance >= _pipTriggerDistance || - velocity > 600; // Simple velocity threshold + _pipDragDistance.value >= _pipTriggerDistance || velocity > 600; if (shouldTriggerPip) { - // Simple haptic feedback on success HapticFeedback.mediumImpact(); _videoStore.requestPictureInPicture(); _resetDragState(); } else { - // Animate back to original position _animateSpringBack(); } } @@ -182,16 +194,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, end: 0) - .animate( + _springBackAnimation = + Tween(begin: _pipDragDistance.value, end: 0).animate( CurvedAnimation(parent: _animationController, curve: Curves.easeOut), ); @@ -229,13 +241,13 @@ class _VideoChatState extends State ? AspectRatio(aspectRatio: aspectRatio, child: child) : child; - return AnimatedBuilder( - animation: _animationController, + // 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 = _isPipDragging - ? _pipDragDistance - : _springBackAnimation.value; + final currentDragDistance = _pipDragDistance.value; final scaleFactor = 1.0 - (currentDragDistance / _pipMaxDragDistance * 0.1); @@ -256,11 +268,13 @@ class _VideoChatState extends State if (!_videoStore.isInPipMode) Positioned.fill( child: IgnorePointer( - ignoring: !(_isPipDragging && _pipDragDistance > 0), + ignoring: + !(_isPipDragging && currentDragDistance > 0), child: AnimatedOpacity( - opacity: (_isPipDragging && _pipDragDistance > 0) - ? 1.0 - : 0.0, + opacity: + (_isPipDragging && currentDragDistance > 0) + ? 1.0 + : 0.0, duration: const Duration(milliseconds: 150), curve: Curves.easeOut, child: Container( @@ -297,7 +311,12 @@ class _VideoChatState extends State final player = GestureDetector( onLongPress: _videoStore.handleToggleOverlay, - child: Video(key: _videoKey, videoStore: _videoStore), + child: _videoStore is NativeVideoStore + ? NativeVideo(nativeVideoStore: _videoStore) + : Video( + key: _videoKey, + videoStore: _videoStore as VideoStore, + ), ); final overlay = GestureDetector( @@ -324,7 +343,9 @@ class _VideoChatState extends State settingsStore: settingsStore, ); - if (_videoStore.paused || _videoStore.streamInfo == null) { + if (_videoStore.paused || + _videoStore.streamInfo == null || + _videoStore.loading) { return videoOverlay; } @@ -344,15 +365,49 @@ class _VideoChatState extends State ), ); - final video = Observer( - builder: (context) { - return Stack( - children: [ - player, - if (_videoStore.settingsStore.showOverlay) overlay, - ], - ); - }, + final video = Stack( + children: [ + player, + Positioned.fill( + child: Observer( + builder: (_) => AnimatedOpacity( + opacity: _videoStore.loading ? 1.0 : 0.0, + // Appear instantly to cover native player placeholders during + // recovery; fade out smoothly when playback resumes. + duration: _videoStore.loading + ? Duration.zero + : const Duration(milliseconds: 300), + curve: Curves.easeOut, + // TickerMode stops the spinner's repeating animation while + // hidden — opacity 0 skips painting but would otherwise keep + // scheduling frames for the whole video session. + child: IgnorePointer( + child: TickerMode( + enabled: _videoStore.loading, + child: const ColoredBox( + color: Colors.black, + child: Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + color: Colors.white38, + strokeWidth: 2, + ), + ), + ), + ), + ), + ), + ), + ), + ), + Observer( + builder: (_) => _videoStore.settingsStore.showOverlay + ? overlay + : const SizedBox.shrink(), + ), + ], ); final chat = Observer( @@ -683,7 +738,7 @@ class _VideoChatState extends State if (settingsStore.showVideo) ...[ AspectRatio( aspectRatio: 16 / 9, - child: Container(), // Placeholder for video space + child: SizedBox(), ), ], Expanded(child: chat), @@ -710,9 +765,18 @@ class _VideoChatState extends State // If on Android, use PiPSwitcher to enable PiP functionality. if (Platform.isAndroid) { + void notifyPip(bool isInPip) { + final store = _videoStore; + if (store is NativeVideoPlayerInterface) { + store.handleAndroidPipChanged(isInPip); + } + } + return PipWidget( pipLayout: PipActionsLayout.mediaOnlyPause, onPipAction: (_) => _videoStore.handlePausePlay(), + onPipEntered: () => notifyPip(true), + onPipExited: () => notifyPip(false), pipChild: player, child: videoChat, ); @@ -731,6 +795,7 @@ 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 0f5f08bb..135940ed 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -1,4 +1,5 @@ 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'; @@ -11,6 +12,10 @@ 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; @@ -145,6 +150,7 @@ class Chat extends StatelessWidget { ), ), addAutomaticKeepAlives: false, + scrollCacheExtent: _chatCacheExtent, controller: scrollController, itemCount: chatStore.renderMessages.length, itemBuilder: (context, index) => ChatMessage( @@ -172,6 +178,7 @@ class Chat extends StatelessWidget { ), ), addAutomaticKeepAlives: false, + scrollCacheExtent: _chatCacheExtent, controller: scrollController, itemCount: mergedMessages.length, itemBuilder: (context, index) { diff --git a/lib/screens/channel/chat/details/chat_modes.dart b/lib/screens/channel/chat/details/chat_modes.dart index b43ec476..403381dc 100644 --- a/lib/screens/channel/chat/details/chat_modes.dart +++ b/lib/screens/channel/chat/details/chat_modes.dart @@ -171,7 +171,7 @@ class ChatModes extends StatelessWidget { style: TextStyle( color: Theme.of( context, - ).colorScheme.onSurfaceVariant, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.7), ), ), ], diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index 4ed851ea..d0f9ed6e 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -15,6 +15,7 @@ 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'; @@ -66,32 +67,41 @@ abstract class ChatStoreBase with Store { 'msg_bad_characters', }; - /// Base height of the bottom bar (input field area). + /// Fallback height used to pad the message list for one frame, until the + /// real bottom bar reports its measured height via [setBottomBarHeight]. static const _baseBottomBarHeight = 68.0; - /// Height of the autocomplete bar (SizedBox + Divider). - static const _autocompleteHeight = 51.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 reply bar (Container + Divider). - static const _replyBarHeight = 41.0; + /// Muted words pre-normalized (trimmed, lowercased, empties dropped) by a + /// reaction on the setting — avoids re-normalizing on every message. + var _normalizedMutedWords = []; - /// 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; + /// 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]); } - int get _chatDelaySeconds => settings.effectiveChatDelay.toInt(); + Duration get _chatDelay => Duration( + milliseconds: (settings.effectiveChatDelay * 1000).round(), + ); - bool get _hasActiveChatDelay => settings.showVideo && _chatDelaySeconds > 0; + bool get _hasActiveChatDelay => settings.showVideo && _chatDelay > Duration.zero; final TwitchApi twitchApi; @@ -131,6 +141,12 @@ 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; @@ -158,6 +174,12 @@ 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 = []; @@ -300,25 +322,26 @@ abstract class ChatStoreBase with Store { .toList(); } - /// Current bottom bar height based on visible overlays. - /// Used to dynamically adjust chat message list padding. + /// Measured height of the rendered bottom bar (input plus any reply or + /// autocomplete overlays). The bar reports this via [setBottomBarHeight] so + /// the message list's bottom padding tracks the bar's *true* height — + /// including multi-line input growth and OS text scaling, which fixed + /// per-overlay constants silently undercount. + final _measuredBottomBarHeight = Observable(null); + + /// Current bottom bar height, used to dynamically pad the message list so the + /// bar never overlaps messages. Falls back to the single-line base height + /// until the real bar reports its first measurement. @computed - double get bottomBarHeight { - var height = _baseBottomBarHeight; + double get bottomBarHeight => + _measuredBottomBarHeight.value ?? _baseBottomBarHeight; - // 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; + /// Reports the bar's real rendered height. Sub-pixel changes are ignored to + /// avoid needless message-list relayouts. + void setBottomBarHeight(double height) { + final current = _measuredBottomBarHeight.value; + if (current != null && (current - height).abs() < 0.5) return; + runInAction(() => _measuredBottomBarHeight.value = height); } ChatStoreBase({ @@ -337,6 +360,21 @@ 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( (_) => [ @@ -449,7 +487,7 @@ abstract class ChatStoreBase with Store { ) { queue.add( _DelayedCallback( - releaseAt: DateTime.now().add(Duration(seconds: _chatDelaySeconds)), + releaseAt: DateTime.now().add(_chatDelay), callback: callback, ), ); @@ -518,6 +556,15 @@ 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 @@ -549,19 +596,18 @@ abstract class ChatStoreBase with Store { 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; - } - } + // Filter messages containing any muted words. Must use `continue` + // (not `return`) — a single IRC frame can batch many messages, and + // returning would drop the rest of the frame with the matched one. + if (parsedIRCMessage.message != null && + _normalizedMutedWords.isNotEmpty) { + final msg = parsedIRCMessage.message!.toLowerCase(); + final words = settings.matchWholeWord ? msg.split(' ') : null; + final muted = _normalizedMutedWords.any( + (word) => + words != null ? words.contains(word) : msg.contains(word), + ); + if (muted) continue; } switch (parsedIRCMessage.command) { @@ -577,6 +623,9 @@ 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) { @@ -645,23 +694,13 @@ abstract class ChatStoreBase with Store { case Command.userState: _userState = _userState.fromIRCMessage(parsedIRCMessage); - // 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; + // A USERSTATE following our PRIVMSG confirms the send. Finalize + // regardless of whether a local echo was pre-built — gating this on + // `toSend` left the player stuck "sending" (and firing a false + // "may not have been sent") whenever the message went out before + // this session's first USERSTATE populated `_userState`. + if (_isWaitingForAck) { + _finalizeSend(parsedIRCMessage.tags['id']); } break; case Command.globalUserState: @@ -825,8 +864,9 @@ abstract class ChatStoreBase with Store { _sevenTVChannel?.sink.close(1000); _clearPendingDelayedCallbacks(clearChat: false); - _sevenTVChannel = WebSocketChannel.connect( + _sevenTVChannel = IOWebSocketChannel.connect( Uri.parse('wss://events.7tv.io/v3'), + pingInterval: _pingInterval, ); void listener(dynamic data) { @@ -913,8 +953,9 @@ abstract class ChatStoreBase with Store { _clearPendingDelayedCallbacks(clearSevenTV: false); _channel?.sink.close(1000); - _channel = WebSocketChannel.connect( + _channel = IOWebSocketChannel.connect( Uri.parse('wss://irc-ws.chat.twitch.tv:443'), + pingInterval: _pingInterval, ); // Only show chat delay countdown on initial connection or video toggle, not on reconnects @@ -927,13 +968,27 @@ abstract class ChatStoreBase with Store { (data) { final dataStr = data.toString(); - // Process immediately if delay is disabled or command should bypass delay - if (!_hasActiveChatDelay || _shouldBypassDelay(dataStr)) { + // No active delay: process the whole frame immediately. + if (!_hasActiveChatDelay) { _handleIRCData(dataStr); - } else { - _enqueueDelayedCallback(_pendingChatCallbacks, () { - _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), + ); + } } }, onError: (error) { @@ -954,6 +1009,14 @@ 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; @@ -1124,7 +1187,7 @@ abstract class ChatStoreBase with Store { messageBuffer.clear(); } - var remainingSeconds = _chatDelaySeconds; + var remainingSeconds = _chatDelay.inSeconds; // Create and store reference to the countdown message _countdownMessage = IRCMessage.createNotice( @@ -1209,53 +1272,92 @@ 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) - // Start timeout timer in case server doesn't respond (network failure) - // Must be outside userStateString conditional to always have a safety net + // Fallback in case the confirming USERSTATE never arrives. Twitch can omit + // or drop the USERSTATE that acknowledges a send — intermittently, and more + // often under a long chat delay — even though the message was delivered. + // A missing ack is therefore NOT evidence of failure, so rather than cry + // wolf with a "may not have been sent" error (which pushes the user to + // resend and double-post), optimistically finalize the send: render the + // local echo and clear the pending state. Genuine rejections almost always + // arrive first — the rejection NOTICE bypasses the chat delay, so it lands + // well within this window and clears the pending send before the timeout. A + // rejection arriving after the timeout has finalized is treated as sent (the + // echo stays), an accepted edge given how early NOTICEs arrive. Scaled by + // the chat delay so a legitimately delayed USERSTATE is given time to land. _sendingTimeoutTimer?.cancel(); - _sendingTimeoutTimer = Timer(const Duration(seconds: 10), () { - // Guard against execution after disposal - if (_shouldDisconnect) return; - - if (_isWaitingForAck) { - _isWaitingForAck = false; - toSend = null; - HapticFeedback.heavyImpact(); - updateNotification('Message may not have been sent. Please try again.'); - } + _sendingTimeoutTimer = Timer(const Duration(seconds: 10) + _chatDelay, () { + if (_shouldDisconnect || !_isWaitingForAck) return; + runInAction(() => _finalizeSend(null)); }); - // Obtain the logged-in user's appearance in chat with USERSTATE and create the full message to render. - var userStateString = _userState.raw; - if (userStateString != null) { - if (message.length > 3 && message.substring(0, 3) == '/me') { - userStateString += - ' :\x01ACTION ${message.replaceRange(0, 3, '').trim()}\x01'; - } else { - userStateString += - ' :${replyingToMessage?.tags['display-name'] != null ? '@${replyingToMessage!.tags['display-name']} ' : ''}${message.trim()}'; - } + // 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); + } - final userChatMessage = IRCMessage.fromString(userStateString); - userChatMessage.localEmotes?.addAll(assetsStore.userEmoteToObject); - if (auth.isLoggedIn && auth.user.details != null) { - userChatMessage.tags['user-id'] = auth.user.details!.id; - } - - if (replyingToMessage != null && replyingToMessage!.tags['id'] != null) { - userChatMessage.tags['reply-parent-msg-id'] = - replyingToMessage!.tags['id']!; - userChatMessage.tags['reply-parent-display-name'] = - replyingToMessage!.tags['display-name'] ?? ''; - userChatMessage.tags['reply-parent-msg-body'] = - replyingToMessage!.message ?? ''; - } - - // Ensure a timestamp so merged-mode sorting works correctly. - userChatMessage.tags['tmi-sent-ts'] ??= - '${DateTime.now().millisecondsSinceEpoch}'; - - toSend = userChatMessage; + /// Renders the pending outgoing message's local echo (building it lazily if + /// it couldn't be built at send time) and clears all send-pending state. + /// Shared by the USERSTATE confirmation and the optimistic timeout fallback. + /// [serverMessageId] is the `id` tag from the confirming USERSTATE, or null + /// when finalizing optimistically without one. + void _finalizeSend(String? serverMessageId) { + toSend ??= _pendingSentMessageText != null + ? _buildLocalEcho(_pendingSentMessageText!) + : null; + if (toSend != null) { + // Prefer the server id; fall back to any id already set, then a local one. + toSend!.tags['id'] = serverMessageId ?? + toSend!.tags['id'] ?? + 'local-${DateTime.now().millisecondsSinceEpoch}'; + messageBuffer.add(toSend!); + toSend = null; } + _pendingSentMessageText = null; + _sendingTimeoutTimer?.cancel(); + _isWaitingForAck = false; + textController.clear(); + replyingToMessage = null; + } + + /// Builds the optimistic local echo of an outgoing [message] from the current + /// USERSTATE, or null if no USERSTATE has been received yet this session + /// (the echo can't be rendered without the user's badges/color/display-name). + IRCMessage? _buildLocalEcho(String message) { + var userStateString = _userState.raw; + if (userStateString == null) return null; + + if (message == '/me' || message.startsWith('/me ')) { + userStateString += + ' :\x01ACTION ${message.replaceRange(0, 3, '').trim()}\x01'; + } else { + userStateString += + ' :${replyingToMessage?.tags['display-name'] != null ? '@${replyingToMessage!.tags['display-name']} ' : ''}${message.trim()}'; + } + + final userChatMessage = IRCMessage.fromString(userStateString); + userChatMessage.localEmotes?.addAll(assetsStore.userEmoteToObject); + if (auth.isLoggedIn && auth.user.details != null) { + userChatMessage.tags['user-id'] = auth.user.details!.id; + } + + if (replyingToMessage != null && replyingToMessage!.tags['id'] != null) { + userChatMessage.tags['reply-parent-msg-id'] = + replyingToMessage!.tags['id']!; + userChatMessage.tags['reply-parent-display-name'] = + replyingToMessage!.tags['display-name'] ?? ''; + userChatMessage.tags['reply-parent-msg-body'] = + replyingToMessage!.message ?? ''; + } + + // Ensure a timestamp so merged-mode sorting works correctly. + userChatMessage.tags['tmi-sent-ts'] ??= + '${DateTime.now().millisecondsSinceEpoch}'; + + return userChatMessage; } /// Adds the given [emote] to the chat textfield. diff --git a/lib/screens/channel/chat/stores/chat_tabs_store.dart b/lib/screens/channel/chat/stores/chat_tabs_store.dart index 8d3f1c8d..c5c2819d 100644 --- a/lib/screens/channel/chat/stores/chat_tabs_store.dart +++ b/lib/screens/channel/chat/stores/chat_tabs_store.dart @@ -55,6 +55,10 @@ 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; @@ -515,10 +519,9 @@ abstract class ChatTabsStoreBase with Store { ), ); - // Switch to the new tab (this will activate it) + // Switch to the new tab, snapshotting the outgoing tab's lastSeenMessageId. final newIndex = _tabs.length - 1; - activateTab(newIndex); - activeTabIndex = newIndex; + setActiveTab(newIndex); // If in merged mode, fetch the new tab's channel profile if (mergedMode) { @@ -639,6 +642,30 @@ 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 @@ -647,6 +674,18 @@ abstract class ChatTabsStoreBase with Store { @action void setActiveTab(int index, {bool silent = false}) { if (index >= 0 && index < _tabs.length) { + // Snapshot the outgoing tab's most recent message id so the unread + // dot for that tab clears, and only future messages count as unread. + if (index != activeTabIndex && + activeTabIndex >= 0 && + activeTabIndex < _tabs.length) { + final outgoing = _tabs[activeTabIndex]; + final outStore = outgoing.chatStore; + if (outStore != null) { + outgoing.lastSeenMessageId = _latestMessageId(outStore.messages); + } + } + // Clear text input and emote menu when switching tabs. // Skip in merged mode — tabs act as send-target selectors, not // view switches, so draft/reply state should be preserved. diff --git a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart index 61d4dab7..7f5365f2 100644 --- a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart +++ b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart @@ -11,6 +11,7 @@ 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; @@ -393,6 +394,13 @@ 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( @@ -400,7 +408,7 @@ class ChatBottomBar extends StatelessWidget { ? MediaQuery.of(context).padding.bottom : 0, ), - child: bottomBarContent, + child: measuredBar, ) : BlurredContainer( gradientDirection: GradientDirection.down, @@ -409,7 +417,7 @@ class ChatBottomBar extends StatelessWidget { ? MediaQuery.of(context).padding.bottom : 0, ), - child: bottomBarContent, + child: measuredBar, ); }, ); diff --git a/lib/screens/channel/chat/widgets/chat_message.dart b/lib/screens/channel/chat/widgets/chat_message.dart index d7645750..87e313d4 100644 --- a/lib/screens/channel/chat/widgets/chat_message.dart +++ b/lib/screens/channel/chat/widgets/chat_message.dart @@ -17,6 +17,7 @@ class ChatMessage extends StatelessWidget { final IRCMessage ircMessage; final ChatStore chatStore; final bool isModal; + final bool forceTimestamp; final bool showReplyHeader; final bool isInReplyThread; @@ -39,6 +40,7 @@ class ChatMessage extends StatelessWidget { required this.ircMessage, required this.chatStore, this.isModal = false, + this.forceTimestamp = false, this.showReplyHeader = true, this.isInReplyThread = false, this.overrideChannelIdToUserTwitch, @@ -65,6 +67,7 @@ class ChatMessage extends StatelessWidget { username: ircMessage.user!, userId: ircMessage.tags['user-id']!, displayName: ircMessage.tags['display-name']!, + onActivateSourceTab: onActivateSourceTab, ), ); } @@ -84,6 +87,7 @@ class ChatMessage extends StatelessWidget { username: user.login, userId: user.id, displayName: user.displayName, + onActivateSourceTab: onActivateSourceTab, ), ); } @@ -126,7 +130,11 @@ class ChatMessage extends StatelessWidget { badgeScale: chatStore.settings.badgeScale, launchExternal: chatStore.settings.launchUrlExternal, timestamp: chatStore.settings.timestampType, - channelIdToUserTwitch: overrideChannelIdToUserTwitch ?? + forceTimestamp: forceTimestamp, + showHistoricalTimestamps: + chatStore.settings.showHistoricalTimestamps, + channelIdToUserTwitch: + overrideChannelIdToUserTwitch ?? chatStore.assetsStore.channelIdToUserTwitch, currentChannelId: overrideCurrentChannelId ?? chatStore.channelId, @@ -234,7 +242,11 @@ class ChatMessage extends StatelessWidget { badgeScale: chatStore.settings.badgeScale, launchExternal: chatStore.settings.launchUrlExternal, timestamp: chatStore.settings.timestampType, - channelIdToUserTwitch: overrideChannelIdToUserTwitch ?? + forceTimestamp: forceTimestamp, + showHistoricalTimestamps: + chatStore.settings.showHistoricalTimestamps, + channelIdToUserTwitch: + overrideChannelIdToUserTwitch ?? chatStore.assetsStore.channelIdToUserTwitch, currentChannelId: overrideCurrentChannelId ?? chatStore.channelId, @@ -389,9 +401,12 @@ class ChatMessage extends StatelessWidget { }, launchExternal: chatStore.settings.launchUrlExternal, timestamp: chatStore.settings.timestampType, + forceTimestamp: forceTimestamp, + showHistoricalTimestamps: + chatStore.settings.showHistoricalTimestamps, channelIdToUserTwitch: overrideChannelIdToUserTwitch ?? - chatStore.assetsStore.channelIdToUserTwitch, + chatStore.assetsStore.channelIdToUserTwitch, currentChannelId: overrideCurrentChannelId ?? chatStore.channelId, ), @@ -482,10 +497,14 @@ class ChatMessage extends StatelessWidget { messageHeaderIcon = Icon( Icons.campaign_rounded, size: messageHeaderIconSize, + color: messageHeaderTextColor, ); - messageHeader = const Text( + messageHeader = Text( 'Announcement', - style: TextStyle(fontWeight: messageHeaderFontWeight), + style: TextStyle( + fontWeight: messageHeaderFontWeight, + color: messageHeaderTextColor, + ), ); } @@ -514,9 +533,12 @@ class ChatMessage extends StatelessWidget { badgeScale: chatStore.settings.badgeScale, launchExternal: chatStore.settings.launchUrlExternal, timestamp: chatStore.settings.timestampType, + forceTimestamp: forceTimestamp, + showHistoricalTimestamps: + chatStore.settings.showHistoricalTimestamps, channelIdToUserTwitch: overrideChannelIdToUserTwitch ?? - chatStore.assetsStore.channelIdToUserTwitch, + chatStore.assetsStore.channelIdToUserTwitch, currentChannelId: overrideCurrentChannelId ?? chatStore.channelId, ), @@ -589,6 +611,19 @@ class ChatMessage extends StatelessWidget { child: dividedMessage, ); + final sourceChannelId = + ircMessage.tags['source-room-id'] ?? ircMessage.tags['room-id']; + final currentChannelId = + overrideCurrentChannelId ?? chatStore.channelId; + final shouldFade = + chatStore.settings.focusCurrentChannel && + sourceChannelId != null && + sourceChannelId != currentChannelId; + + final fadedMessage = shouldFade + ? Opacity(opacity: 0.55, child: coloredMessage) + : coloredMessage; + final finalMessage = InkWell( onTap: () { FocusScope.of(context).unfocus(); @@ -597,7 +632,7 @@ class ChatMessage extends StatelessWidget { } }, onLongPress: () => onLongPressMessage(context, defaultTextStyle), - child: coloredMessage, + child: fadedMessage, ); return finalMessage; diff --git a/lib/screens/channel/chat/widgets/chat_tabs.dart b/lib/screens/channel/chat/widgets/chat_tabs.dart index fe946202..724564aa 100644 --- a/lib/screens/channel/chat/widgets/chat_tabs.dart +++ b/lib/screens/channel/chat/widgets/chat_tabs.dart @@ -38,7 +38,6 @@ 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'), @@ -56,16 +55,10 @@ class ChatTabs extends StatelessWidget { final tabs = chatTabsStore.tabs; final activeIndex = chatTabsStore.activeTabIndex; final showTabBar = chatTabsStore.showTabBar; - final showMerge = - tabs.where((t) => t.isActivated).length >= 2; + final showMerge = tabs.where((t) => t.isActivated).length >= 2; - // Calculate extra top padding for tab bar when visible final tabBarHeight = showTabBar ? 48.0 : 0.0; - - // Get the top inset from listPadding (e.g., for AppBar in chat-only mode) final topInset = listPadding?.resolve(TextDirection.ltr).top ?? 0; - - // Adjust list padding to account for tab bar final adjustedPadding = listPadding != null ? listPadding!.add(EdgeInsets.only(top: tabBarHeight)) : EdgeInsets.only(top: tabBarHeight); @@ -75,27 +68,20 @@ class ChatTabs extends StatelessWidget { onPopInvokedWithResult: (didPop, _) { if (didPop) return; - // On Android back gesture, close overlays first before navigating back + // Android back gesture: dismiss overlays before navigating away. final activeStore = chatTabsStore.activeChatStore; - - // Priority 1: Close emote menu if (activeStore.assetsStore.showEmoteMenu) { activeStore.assetsStore.showEmoteMenu = false; return; } - - // Priority 2: Unfocus keyboard if (activeStore.textFieldFocusNode.hasFocus) { activeStore.unfocusInput(); return; } - - // Priority 3: Navigate back Navigator.of(context).pop(); }, child: Stack( children: [ - // Chat content: merged view or IndexedStack Positioned.fill( child: chatTabsStore.mergedMode ? Chat( @@ -107,7 +93,6 @@ class ChatTabs extends StatelessWidget { : IndexedStack( index: activeIndex, children: tabs.map((tabInfo) { - // Show placeholder for non-activated tabs if (tabInfo.chatStore == null) { return const SizedBox.shrink(); } @@ -120,7 +105,6 @@ class ChatTabs extends StatelessWidget { }).toList(), ), ), - // Tab bar (only visible when more than 1 tab) if (showTabBar) Positioned( top: topInset, @@ -130,8 +114,8 @@ class ChatTabs extends StatelessWidget { height: 48, child: Stack( children: [ - // When merge button is visible, clips at its center - // so chips slide under its left half then disappear. + // Right-clip past the merge button so chips slide + // under its left half then disappear. Positioned( left: 0, top: 0, @@ -161,7 +145,7 @@ class ChatTabs extends StatelessWidget { padding: EdgeInsets.only( right: index < tabs.length - 1 ? 4 : 0, ), - child: _buildTab(context, index), + child: Center(child: _buildTab(context, index)), ); }, ), @@ -171,7 +155,9 @@ class ChatTabs extends StatelessWidget { right: 12, top: 0, bottom: 0, - child: Center(child: _buildMergeToggle(context)), + child: Center( + child: _buildMoreActionsMenu(context), + ), ), ], ), @@ -184,22 +170,47 @@ class ChatTabs extends StatelessWidget { ); } - Widget _buildMergeToggle(BuildContext context) { - final isMerged = chatTabsStore.mergedMode; - return IconButton.filledTonal( - icon: const Icon(Icons.call_merge, size: 18), - tooltip: isMerged ? 'Split chats' : 'Merge loaded chats', - visualDensity: VisualDensity.compact, - isSelected: isMerged, - style: IconButton.styleFrom( - minimumSize: const Size(42, 42), - backgroundColor: isMerged - ? null - : Theme.of(context).colorScheme.surfaceContainerHighest, - ), - onPressed: () { - HapticFeedback.selectionClick(); - chatTabsStore.toggleMergedMode(); + Widget _buildMoreActionsMenu(BuildContext context) { + final settings = chatTabsStore.activeChatStore.settings; + + return _AnchoredPopupMenu( + itemsBuilder: (close) => [ + Observer( + builder: (_) => CheckboxMenuButton( + value: chatTabsStore.mergedMode, + onChanged: (_) { + HapticFeedback.selectionClick(); + chatTabsStore.toggleMergedMode(); + }, + child: const Text('Merge chats'), + ), + ), + Observer( + builder: (_) => CheckboxMenuButton( + value: settings.focusCurrentChannel, + onChanged: (newValue) { + HapticFeedback.selectionClick(); + settings.focusCurrentChannel = newValue ?? false; + }, + child: const Text('Focus current channel'), + ), + ), + ], + anchorBuilder: (context, toggle) { + return IconButton.filledTonal( + icon: const Icon(Icons.more_vert, size: 18), + tooltip: 'Chat options', + visualDensity: VisualDensity.compact, + style: IconButton.styleFrom( + minimumSize: const Size(42, 42), + backgroundColor: + Theme.of(context).colorScheme.surfaceContainerHighest, + ), + onPressed: () { + HapticFeedback.selectionClick(); + toggle(); + }, + ); }, ); } @@ -207,86 +218,208 @@ class ChatTabs extends StatelessWidget { Widget _buildTab(BuildContext context, int index) { final tabInfo = chatTabsStore.tabs[index]; final isActive = index == chatTabsStore.activeTabIndex; - final isActivated = tabInfo.isActivated; + final isSecondary = !tabInfo.isPrimary; final displayName = getReadableName( tabInfo.displayName, tabInfo.channelLogin, ); - final avatar = ProfilePicture(userLogin: tabInfo.channelLogin, radius: 12); + 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); - if (tabInfo.isPrimary) { - return InputChip( - avatar: avatar, - label: Text(displayName), - selected: isActive, - showCheckmark: false, - visualDensity: VisualDensity.compact, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - onPressed: () { - if (!isActive) { - HapticFeedback.selectionClick(); - chatTabsStore.setActiveTab(index); - } - }, - ); - } - - return MenuAnchor( - menuChildren: [ - if (isActivated) - MenuItemButton( - leadingIcon: const Icon(Icons.power_off_rounded, size: 18), - child: const Text('Disconnect'), - onPressed: () { - HapticFeedback.lightImpact(); - chatTabsStore.deactivateTab(index); - }, - ), - MenuItemButton( - leadingIcon: Icon( - Icons.delete_outline_rounded, - size: 18, - color: Theme.of(context).colorScheme.error, - ), - child: Text( - 'Remove', - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - onPressed: () { - HapticFeedback.lightImpact(); - chatTabsStore.removeTab(index); - }, - ), - ], - builder: (context, controller, child) { - return InputChip( - avatar: - isActivated ? avatar : Opacity(opacity: 0.5, child: avatar), - label: Text( - displayName, - style: isActivated - ? null - : TextStyle( - color: Theme.of( - context, - ).textTheme.bodyMedium?.color?.withValues(alpha: 0.5), + 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, ), - ), - selected: isActive, - showCheckmark: false, - visualDensity: VisualDensity.compact, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - onPressed: () { - if (!isActive) { - HapticFeedback.selectionClick(); - chatTabsStore.setActiveTab(index); - } - }, - onDeleted: () => controller.open(), - deleteButtonTooltipMessage: 'Tab options', + ), + ), + 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), ); }, ); } } + +/// Lightweight popup menu anchored to a child widget. The menu's top-right +/// aligns to the anchor's bottom-right via [CompositedTransformFollower] and +/// fades + scales in from that corner. [MenuAnchor] doesn't expose an entry +/// animation hook in this Flutter version, so we drive one ourselves. +class _AnchoredPopupMenu extends StatefulWidget { + final Widget Function(BuildContext context, VoidCallback toggle) + anchorBuilder; + final List Function(VoidCallback close) itemsBuilder; + + const _AnchoredPopupMenu({ + required this.anchorBuilder, + required this.itemsBuilder, + }); + + @override + State<_AnchoredPopupMenu> createState() => _AnchoredPopupMenuState(); +} + +class _AnchoredPopupMenuState extends State<_AnchoredPopupMenu> + with SingleTickerProviderStateMixin { + final _link = LayerLink(); + final _portalCtrl = OverlayPortalController(); + late final _anim = AnimationController( + duration: const Duration(milliseconds: 150), + reverseDuration: const Duration(milliseconds: 100), + vsync: this, + )..addStatusListener((status) { + if (status == AnimationStatus.dismissed && _portalCtrl.isShowing) { + _portalCtrl.hide(); + } + }); + late final _curve = + CurvedAnimation(parent: _anim, curve: Curves.easeOut); + late final _scale = Tween(begin: 0.92, end: 1.0).animate(_curve); + + @override + void dispose() { + _curve.dispose(); + _anim.dispose(); + super.dispose(); + } + + void _toggle() { + if (_portalCtrl.isShowing) { + _anim.reverse(); + } else { + _portalCtrl.show(); + _anim.forward(); + } + } + + void _close() { + if (_portalCtrl.isShowing) _anim.reverse(); + } + + @override + Widget build(BuildContext context) { + return CompositedTransformTarget( + link: _link, + child: OverlayPortal( + controller: _portalCtrl, + overlayChildBuilder: (overlayContext) { + final theme = Theme.of(overlayContext); + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _close, + ), + ), + CompositedTransformFollower( + link: _link, + targetAnchor: Alignment.bottomRight, + followerAnchor: Alignment.topRight, + offset: const Offset(0, 4), + showWhenUnlinked: false, + child: FadeTransition( + opacity: _anim, + child: ScaleTransition( + scale: _scale, + alignment: Alignment.topRight, + child: Material( + color: theme.colorScheme.surface, + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all( + Radius.circular(12), + ), + side: BorderSide( + color: theme.colorScheme.outlineVariant + .withValues(alpha: 0.5), + width: 0.5, + ), + ), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: IntrinsicWidth( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: widget.itemsBuilder(_close), + ), + ), + ), + ), + ), + ), + ), + ], + ); + }, + child: widget.anchorBuilder(context, _toggle), + ), + ); + } +} diff --git a/lib/screens/channel/chat/widgets/chat_user_modal.dart b/lib/screens/channel/chat/widgets/chat_user_modal.dart index 2e0700ae..07c975a5 100644 --- a/lib/screens/channel/chat/widgets/chat_user_modal.dart +++ b/lib/screens/channel/chat/widgets/chat_user_modal.dart @@ -14,6 +14,7 @@ class ChatUserModal extends StatefulWidget { final String username; final String displayName; final String userId; + final VoidCallback? onActivateSourceTab; const ChatUserModal({ super.key, @@ -21,6 +22,7 @@ class ChatUserModal extends StatefulWidget { required this.username, required this.displayName, required this.userId, + this.onActivateSourceTab, }); @override @@ -61,6 +63,7 @@ class _ChatUserModalState extends State { IconButton( tooltip: 'Reply', onPressed: () { + widget.onActivateSourceTab?.call(); widget.chatStore.textController.text = '@${widget.username} '; Navigator.pop(context); @@ -115,6 +118,7 @@ class _ChatUserModalState extends State { ircMessage: userMessages[index], chatStore: widget.chatStore, isModal: true, + forceTimestamp: true, ), ), ), diff --git a/lib/screens/channel/chat/widgets/reply_thread.dart b/lib/screens/channel/chat/widgets/reply_thread.dart index 734408b3..7dc83394 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.w500), + style: const TextStyle(fontWeight: FontWeight.w600), ), ), Flexible( diff --git a/lib/screens/channel/video/chat_latency_sync.dart b/lib/screens/channel/video/chat_latency_sync.dart new file mode 100644 index 00000000..0b5ff544 --- /dev/null +++ b/lib/screens/channel/video/chat_latency_sync.dart @@ -0,0 +1,32 @@ +import 'package:frosty/screens/channel/video/video_timing_constants.dart'; +import 'package:frosty/screens/settings/stores/settings_store.dart'; + +/// Single owner of `SettingsStore.syncedChatDelay` writes from the video +/// player stores. Previously both stores poked the setting directly, +/// making settings an ad-hoc event bus between video and chat. +class ChatLatencySync { + ChatLatencySync({required this.settingsStore}); + + final SettingsStore settingsStore; + + /// Reset the delay to zero on stream transitions (channel enter, stream + /// end, chat-only toggle, etc). No-op when auto-sync is disabled. + void reset() { + if (settingsStore.autoSyncChatDelay) { + settingsStore.syncedChatDelay = 0.0; + } + } + + /// Push a fresh video-latency measurement. Only writes when drift from + /// the current value exceeds the tolerance, to avoid thrashing the chat + /// delay countdown on minor fluctuations. + void report(double latencySeconds) { + if (!settingsStore.autoSyncChatDelay) return; + final current = settingsStore.syncedChatDelay; + if (current == 0 || + (latencySeconds - current).abs() > + VideoTimingConstants.chatSyncDriftToleranceSeconds) { + settingsStore.syncedChatDelay = latencySeconds; + } + } +} diff --git a/lib/screens/channel/video/native_video.dart b/lib/screens/channel/video/native_video.dart new file mode 100644 index 00000000..78c51f52 --- /dev/null +++ b/lib/screens/channel/video/native_video.dart @@ -0,0 +1,106 @@ +import 'package:better_native_video_player/better_native_video_player.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/screens/channel/video/native_video_store.dart'; + +/// Native video player widget backed by AVPlayerViewController (iOS) +/// and ExoPlayer (Android). +class NativeVideo extends StatelessWidget { + final NativeVideoStore nativeVideoStore; + + const NativeVideo({super.key, required this.nativeVideoStore}); + + @override + Widget build(BuildContext context) { + return Observer( + builder: (context) { + final controller = nativeVideoStore.controller; + final error = nativeVideoStore.error; + + final isOffline = + !nativeVideoStore.loading && + nativeVideoStore.streamInfo == null && + error == null; + + return ColoredBox( + color: Colors.black, + child: Stack( + children: [ + if (controller != null && !isOffline) + NativeVideoPlayer( + key: ObjectKey(controller), + controller: controller, + ), + if (controller != null && !nativeVideoStore.hasPlayedOnce && !isOffline) + const ColoredBox( + color: Colors.black, + child: SizedBox.expand(), + ), + if (error != null) + Align( + alignment: const Alignment(0, 0.3), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline_rounded, + color: Colors.amber.withValues(alpha: 0.7), + size: 32, + ), + const SizedBox(height: 10), + Text( + error, + style: const TextStyle( + color: Colors.white60, + fontSize: 13, + height: 1.4, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + if (isOffline) + const Center( + child: Icon( + Icons.tv_off_rounded, + color: Colors.white24, + size: 32, + ), + ), + if (nativeVideoStore.isAudioOnlyMode && !isOffline) + const ColoredBox( + color: Colors.black, + child: SizedBox.expand( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.headphones_rounded, + color: Colors.white24, + size: 32, + ), + SizedBox(height: 8), + Text( + 'Audio Only', + style: TextStyle( + color: Colors.white24, + fontSize: 13, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/screens/channel/video/native_video_player_interface.dart b/lib/screens/channel/video/native_video_player_interface.dart new file mode 100644 index 00000000..766d5a3c --- /dev/null +++ b/lib/screens/channel/video/native_video_player_interface.dart @@ -0,0 +1,12 @@ +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; + + /// Called from platform PiP callbacks on Android. Lets the store sync + /// its overlay state with the OS PiP window. + void handleAndroidPipChanged(bool isInPip); +} diff --git a/lib/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart new file mode 100644 index 00000000..e0b789bc --- /dev/null +++ b/lib/screens/channel/video/native_video_store.dart @@ -0,0 +1,1308 @@ +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_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/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; + + @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; + + @readonly + var _loading = true; + + @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'}', + ); + }); + + _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 PlaybackAccessToken token; + try { + token = await twitchGqlApi.getPlaybackAccessToken( + login: userLogin, + authToken: authToken, + ); + } 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(); + } + token = await twitchGqlApi.getPlaybackAccessToken(login: userLogin); + } else { + rethrow; + } + } + + if (_disposed || generation != _initGeneration || !settingsStore.showVideo) { + return; + } + runInAction( + () => _restrictedStreamQualities = token.restrictedQualities, + ); + final hlsUrl = twitchGqlApi.buildHlsUrl(login: userLogin, token: token); + + await _controller!.loadUrl( + url: hlsUrl, + headers: TwitchGqlApi.playbackHttpHeaders, + ); + if (_disposed || generation != _initGeneration || !settingsStore.showVideo) { + return; + } + await _controller!.configureForLivePlayback(); + _startLatencyPolling(); + } + + /// 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 the standard player in Settings.', + ); + } + }); + } + + /// 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; + } + }); + } + + void _onPlaying() { + _loading = false; + _paused = false; + _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; + _isStalled = false; + _initializing = false; + _isQualitySwitching = false; + _stallRecoveryTimer?.cancel(); + final errorMessage = + eventData?['message'] as String? ?? + 'Playback error. Try refreshing or switch to the standard player in Settings.'; + final errorCode = eventData?['code'] as String?; + final statusCode = eventData?['statusCode'] as int?; + + if (errorCode == 'mediaServicesWereReset') { + _handleMediaServicesReset(errorMessage); + return; + } + + // 4xx on the HLS fetch means the current playback session is gone — a + // fresh GQL token + new query params can land on a different CDN edge + // with a working manifest. Skip the user-facing error and retry + // immediately (handleRefresh updates _lastRefreshTime itself). + if (statusCode != null && + statusCode >= 400 && + statusCode < 500 && + !_refreshCapReached()) { + 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; + } + debugPrint( + 'NativeVideoStore: $statusCode on HLS fetch, refreshing with fresh ' + 'token (attempt ${_totalRefreshAttempts + 1}/${VideoTimingConstants.maxRefreshAttempts})', + ); + _totalRefreshAttempts++; + _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; + _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 the standard player in Settings.' + : 'Stream stalled. Try refreshing or switch to the standard player in Settings.', + ); + }); + } + + /// 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 && _controller != null) { + _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; + _restrictedStreamQualities = []; + _isQualitySwitching = false; + _isAudioOnlyMode = false; + _stallRecoveryAttempt = 0; + if (!preserveHighLatencyCount) _highLatencyCount = 0; + _firstTimeSettingQuality = true; + _pendingQualityIndex = null; + _streamQualityIndex = 0; + _availableStreamQualities = []; + _qualityObjects = []; + _latency = null; + _error = null; + if (!preservePipState) { + _isInPipMode = false; + _lastPipWasAutomatic = false; + _manualPipRequested = false; + _overlayWasVisibleBeforePip = true; + } + } + + /// Resets observable display state after [_disposeController] so the UI + /// reflects "no active player". Called only when entering chat-only mode; + /// final disposal skips this since the store is being torn down. + @action + void _resetPlayerStateForChatOnly() { + _resetPlayerStateCommon(); + _loading = false; + _initializing = false; + _chatLatencySync.reset(); + } + + /// Builds a brand new controller and kicks off `_initPlayer`. Used when + /// the user toggles back to video mode after a chat-only stretch. + @action + void _initFreshController() { + _resetPlayerStateCommon(); + _loading = true; + _initializing = true; + _controller = _createController(); + _initPlayer(); + } +} diff --git a/lib/screens/channel/video/native_video_store.g.dart b/lib/screens/channel/video/native_video_store.g.dart new file mode 100644 index 00000000..8f1167ce --- /dev/null +++ b/lib/screens/channel/video/native_video_store.g.dart @@ -0,0 +1,497 @@ +// 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 _$_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/stream_info_poller.dart b/lib/screens/channel/video/stream_info_poller.dart new file mode 100644 index 00000000..5917ffed --- /dev/null +++ b/lib/screens/channel/video/stream_info_poller.dart @@ -0,0 +1,86 @@ +import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/models/channel.dart'; +import 'package:frosty/models/stream.dart'; +import 'package:frosty/screens/channel/video/video_timing_constants.dart'; + +/// Snapshot of a channel's live state. +class StreamInfoResult { + const StreamInfoResult({this.stream, this.offlineChannel}); + + /// Non-null when the channel is currently live. + final StreamTwitch? stream; + + /// Non-null when the channel is confirmed offline (channel metadata fetched + /// successfully but no live stream). Both fields null means the fetch + /// failed entirely (network error, etc.). + final Channel? offlineChannel; +} + +/// Debounced, deduplicated Twitch stream-info fetcher shared by both video +/// stores. Each store still owns its observable `_streamInfo` / +/// `_offlineChannelInfo` fields and decides how to react to the result. +class StreamInfoPoller { + StreamInfoPoller({ + required this.twitchApi, + required this.userLogin, + required this.userId, + }); + + final TwitchApi twitchApi; + final String userLogin; + final String userId; + + DateTime? _lastUpdate; + Future? _inFlight; + + /// Fetches the latest stream info, returning `null` if the call was + /// debounced (caller should skip updating state). Concurrent calls await + /// the in-flight request instead of issuing a new one. + Future fetch({bool forceUpdate = false}) async { + final pending = _inFlight; + if (pending != null) return pending; + + final now = DateTime.now(); + if (!forceUpdate && _lastUpdate != null) { + if (now.difference(_lastUpdate!) < + VideoTimingConstants.streamInfoDebounce) { + return null; + } + } + + _lastUpdate = now; + final request = _runFetch(); + _inFlight = request; + try { + return await request; + } finally { + if (identical(_inFlight, request)) _inFlight = null; + } + } + + Future _runFetch() async { + try { + final info = await twitchApi.getStream(userLogin: userLogin); + return StreamInfoResult(stream: info); + } catch (_) { + // Helix can transiently drop a live stream from /streams (or 500 on + // one endpoint while others succeed). A false "confirmed offline" + // tears down latency polling and chat delay on healthy playback, so + // require a second failed check before falling back to /channels. + try { + await Future.delayed( + VideoTimingConstants.offlineConfirmationDelay, + ); + final info = await twitchApi.getStream(userLogin: userLogin); + return StreamInfoResult(stream: info); + } catch (_) { + try { + final channel = await twitchApi.getChannel(userId: userId); + return StreamInfoResult(offlineChannel: channel); + } catch (_) { + return const StreamInfoResult(); + } + } + } + } +} diff --git a/lib/screens/channel/video/video_overlay.dart b/lib/screens/channel/video/video_overlay.dart index 62fb13b3..eceb6c08 100644 --- a/lib/screens/channel/video/video_overlay.dart +++ b/lib/screens/channel/video/video_overlay.dart @@ -3,10 +3,12 @@ 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_store.dart'; +import 'package:frosty/screens/channel/video/video_player_interface.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/theme.dart'; import 'package:frosty/utils.dart'; @@ -21,7 +23,7 @@ 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 VideoStore videoStore; + final VideoPlayerInterface videoStore; final ChatStore chatStore; final SettingsStore settingsStore; @@ -88,7 +90,7 @@ class VideoOverlay extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SectionHeader( - 'Stream quality', + 'Quality', padding: EdgeInsets.fromLTRB(16, 0, 16, 8), isFirst: true, ), @@ -97,26 +99,35 @@ 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( - 'last_stream_quality', - quality, - ), - ); - Navigator.pop(context); - }, - ), - ) - .toList(), + 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'), + ), + ), + ], ), ), ), @@ -259,7 +270,6 @@ class VideoOverlay extends StatelessWidget { displayName: chatStore.displayName, showUptime: false, showViewerCount: false, - showOfflineIndicator: false, textColor: surfaceColor, isOffline: true, isInSharedChatMode: @@ -344,32 +354,36 @@ class VideoOverlay extends StatelessWidget { if (videoStore.settingsStore.fullScreen && context.isLandscape) chatOverlayButton, - if (!Platform.isIOS || isIPad()) videoSettingsButton, + if (!Platform.isIOS || + isIPad() || + videoStore is NativeVideoStore) + videoSettingsButton, ], ), - 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), - ), - ], + 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, ), - 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 new file mode 100644 index 00000000..b2507f1f --- /dev/null +++ b/lib/screens/channel/video/video_player_interface.dart @@ -0,0 +1,43 @@ +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; + + void handleVideoTap(); + void handlePausePlay(); + void handleToggleOverlay(); + Future handleRefresh(); + void requestPictureInPicture(); + void togglePictureInPicture(); + Future updateStreamQualities(); + Future setStreamQuality(String quality); + Future updateStreamInfo({bool forceUpdate}); + void handleAppResume(); + void dispose(); +} diff --git a/lib/screens/channel/video/video_store.dart b/lib/screens/channel/video/video_store.dart index 07889964..d75a2718 100644 --- a/lib/screens/channel/video/video_store.dart +++ b/lib/screens/channel/video/video_store.dart @@ -5,8 +5,13 @@ 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'; @@ -20,10 +25,11 @@ part 'video_store.g.dart'; class VideoStore = VideoStoreBase with _$VideoStore; -abstract class VideoStoreBase with Store { +abstract class VideoStoreBase with Store implements VideoPlayerInterface { final TwitchApi twitchApi; /// The userlogin of the current channel. + @override final String userLogin; /// The user ID of the current channel. @@ -31,12 +37,21 @@ abstract class VideoStoreBase with Store { 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]. /// @@ -58,8 +73,6 @@ abstract class VideoStoreBase with Store { 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.]'), @@ -67,8 +80,43 @@ abstract class VideoStoreBase with Store { ); 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) { - settingsStore.syncedChatDelay = latencyAsDouble; + _chatLatencySync.report(latencyAsDouble); } }, ) @@ -86,7 +134,8 @@ abstract class VideoStoreBase with Store { return; } final prefs = await SharedPreferences.getInstance(); - final lastStreamQuality = prefs.getString('last_stream_quality'); + final lastStreamQuality = + prefs.getString(lastStreamQualityKey(userLogin)); if (lastStreamQuality == null) return; setStreamQuality(lastStreamQuality); } @@ -102,6 +151,7 @@ abstract class VideoStoreBase with Store { ..addJavaScriptChannel( 'VideoPlaying', onMessageReceived: (message) { + _loading = false; _paused = false; if (Platform.isAndroid) pip.setIsPlaying(true); }, @@ -152,12 +202,6 @@ abstract class VideoStoreBase with Store { /// 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; - - /// Tracks an in-flight stream info request so refreshes can be deduplicated. - Future? _streamInfoRequest; - /// Disposes the overlay reactions. late final ReactionDisposer _disposeOverlayReaction; @@ -169,6 +213,9 @@ abstract class VideoStoreBase with Store { /// 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. @@ -190,11 +237,17 @@ abstract class VideoStoreBase with Store { @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'; @@ -217,10 +270,9 @@ abstract class VideoStoreBase with Store { required this.authStore, required this.settingsStore, }) { - // Reset chat delay to 0 if auto sync is already enabled to prevent starting with old values - if (settingsStore.autoSyncChatDelay) { - settingsStore.syncedChatDelay = 0.0; - } + // Reset chat delay to 0 to prevent starting with a stale value from + // a previous channel. + _chatLatencySync.reset(); // Initialize the video webview params for iOS to enable video autoplay. if (WebViewPlatform.instance is WebKitWebViewPlatform) { _videoWebViewParams = WebKitWebViewControllerCreationParams( @@ -253,13 +305,7 @@ abstract class VideoStoreBase with Store { await updateStreamQualities(); } else { _showDefaultOverlay(); - try { - videoWebViewController.runJavaScript( - 'window._latencyTracker?.stop()', - ); - } catch (e) { - debugPrint(e.toString()); - } + _stopLatencyTracker(); } }); @@ -277,6 +323,9 @@ abstract class VideoStoreBase with Store { _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(); } }); @@ -308,8 +357,9 @@ abstract class VideoStoreBase with Store { (_) => (settingsStore.showLatency, settingsStore.autoSyncChatDelay), (values) async { final (showLatency, autoSync) = values; - // Only act if overlay is enabled (latency tracker only works with custom overlay) - if (!settingsStore.showOverlay) return; + // 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; if (showLatency || autoSync) { // Start tracker if either setting is now enabled @@ -326,23 +376,18 @@ abstract class VideoStoreBase with Store { ); final latencyAsDouble = double.tryParse(numericPart); if (latencyAsDouble != null) { - settingsStore.syncedChatDelay = latencyAsDouble; + _chatLatencySync.report(latencyAsDouble); } } } else { // Stop tracker if both settings are now disabled - try { - videoWebViewController.runJavaScript( - 'window._latencyTracker?.stop()', - ); - } catch (e) { - debugPrint(e.toString()); - } + _stopLatencyTracker(); } }, ); } + @override @action Future updateStreamQualities() async { try { @@ -382,6 +427,7 @@ abstract class VideoStoreBase with Store { } } + @override @action Future setStreamQuality(String newStreamQuality) async { final indexOfStreamQuality = _availableStreamQualities.indexOf( @@ -492,160 +538,23 @@ abstract class VideoStoreBase with Store { /// - Skips cycles entirely when overlay hidden (saves CPU) Future _listenOnLatencyChanges() async { try { - await videoWebViewController.runJavaScript(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(); - '''); + await videoWebViewController.runJavaScript(_kLatencyTrackerJs); _updateLatencyTrackerVisibility(_overlayVisible); } catch (e) { debugPrint(e.toString()); } } + /// 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()'); + } catch (e) { + debugPrint(e.toString()); + } + } + /// Updates the latency tracker's overlay visibility state. /// /// When overlay is hidden, the latency tracker pauses its polling cycle @@ -708,140 +617,24 @@ abstract class VideoStoreBase with Store { /// Initializes the video webview. @action Future initVideo() async { - 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; - - 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) {} - }); - '''); - 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()); + 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()); } } /// Called whenever the video/overlay is tapped. + @override @action void handleVideoTap() { if (_isInPipMode) { @@ -870,7 +663,7 @@ abstract class VideoStoreBase with Store { // Only start if not already active if (_streamInfoTimer?.isActive != true) { _streamInfoTimer = Timer.periodic( - const Duration(seconds: 60), + VideoTimingConstants.webviewStreamInfoInterval, (_) => updateStreamInfo(), ); } @@ -888,7 +681,7 @@ abstract class VideoStoreBase with Store { if (_jsCleanupTimer?.isActive == true) return; _jsCleanupTimer = Timer.periodic( - const Duration(minutes: 10), + VideoTimingConstants.jsCleanupInterval, (_) => _performJsSoftReset(), ); } @@ -898,7 +691,9 @@ abstract class VideoStoreBase with Store { _jsCleanupTimer = null; } - void _scheduleOverlayHide([Duration delay = const Duration(seconds: 5)]) { + void _scheduleOverlayHide([ + Duration delay = VideoTimingConstants.overlayAutoHide, + ]) { _overlayTimer?.cancel(); if (_isInPipMode) { @@ -918,6 +713,7 @@ abstract class VideoStoreBase with Store { } /// Handles app resume event for immediate stream info refresh in chat-only mode. + @override @action void handleAppResume() { // Only refresh immediately in chat-only mode @@ -930,63 +726,33 @@ abstract class VideoStoreBase with Store { /// /// 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 { - if (_streamInfoRequest != null) { - await _streamInfoRequest; - 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 + final result = await _streamInfoPoller.fetch(forceUpdate: forceUpdate); + if (result == null) return; + runInAction(() { + if (result.stream != null) { + _streamInfo = result.stream; + _offlineChannelInfo = null; + return; } - } - - _lastStreamInfoUpdate = now; - - final request = _updateStreamInfoInternal(); - _streamInfoRequest = request; - - try { - await request; - } finally { - if (identical(_streamInfoRequest, request)) { - _streamInfoRequest = null; - } - } - } - - Future _updateStreamInfoInternal() async { - 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; - - // Try to fetch offline channel information - try { - _offlineChannelInfo = await twitchApi.getChannel(userId: userId); - } catch (channelError) { - _offlineChannelInfo = null; - } - + _offlineChannelInfo = result.offlineChannel; // 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) { @@ -996,7 +762,7 @@ abstract class VideoStoreBase with Store { if (settingsStore.showOverlay) { _overlayVisible = true; - _scheduleOverlayHide(const Duration(seconds: 3)); + _scheduleOverlayHide(VideoTimingConstants.overlayQuickHide); } } @@ -1004,23 +770,25 @@ abstract class VideoStoreBase with Store { } /// Refreshes the stream webview and updates the stream info. + @override @action Future handleRefresh() async { HapticFeedback.lightImpact(); + _loading = true; _paused = true; _firstTimeSettingQuality = true; + _highLatencyCount = 0; _isInPipMode = false; _latency = null; _availableStreamQualities = []; + _overlayTimer?.cancel(); + _scheduleOverlayHide(); + // Signal that initVideo() should run on the next onPageFinished _needsInit = true; - try { - videoWebViewController.runJavaScript('window._latencyTracker?.stop()'); - } catch (e) { - // Ignore - may not exist yet - } + _stopLatencyTracker(); await videoWebViewController.loadRequest(Uri.parse(videoUrl)); @@ -1028,6 +796,7 @@ abstract class VideoStoreBase with Store { } /// Play or pause the video depending on the current state of [_paused]. + @override void handlePausePlay() { try { if (_paused) { @@ -1048,6 +817,7 @@ abstract class VideoStoreBase with Store { /// /// 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) { @@ -1067,6 +837,7 @@ abstract class VideoStoreBase with Store { /// 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 { @@ -1088,6 +859,7 @@ abstract class VideoStoreBase with Store { } } + @override @action void dispose() { // Disable auto PiP when leaving so that we don't enter PiP on other screens. @@ -1109,10 +881,282 @@ abstract class VideoStoreBase with Store { // 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. - try { - videoWebViewController.runJavaScript('window._latencyTracker?.stop()'); - } catch (e) { - // Ignore - page may already be unloading - } + _stopLatencyTracker(); } } + +// --------------------------------------------------------------------------- +// 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 30c79efc..c12540e5 100644 --- a/lib/screens/channel/video/video_store.g.dart +++ b/lib/screens/channel/video/video_store.g.dart @@ -9,6 +9,26 @@ 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, diff --git a/lib/screens/channel/video/video_timing_constants.dart b/lib/screens/channel/video/video_timing_constants.dart new file mode 100644 index 00000000..3fe54344 --- /dev/null +++ b/lib/screens/channel/video/video_timing_constants.dart @@ -0,0 +1,67 @@ +/// 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 7bc450c7..64c32616 100644 --- a/lib/screens/home/home.dart +++ b/lib/screens/home/home.dart @@ -166,13 +166,12 @@ class _HomeState extends State { NavigationDestination( icon: Icon( Icons.favorite_border_rounded, - color: _homeStore.selectedIndex == 0 - ? theme.colorScheme.onSurface - : theme.colorScheme.onSurfaceVariant, + color: theme.colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), selectedIcon: Icon( Icons.favorite_rounded, - color: theme.colorScheme.onSurface, + color: theme.colorScheme.primary, ), label: 'Following', tooltip: 'Following', @@ -180,15 +179,12 @@ class _HomeState extends State { NavigationDestination( icon: Icon( Icons.arrow_upward_rounded, - color: - _homeStore.selectedIndex == - (_authStore.isLoggedIn ? 1 : 0) - ? theme.colorScheme.onSurface - : theme.colorScheme.onSurfaceVariant, + color: theme.colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), selectedIcon: Icon( Icons.arrow_upward_rounded, - color: theme.colorScheme.onSurface, + color: theme.colorScheme.primary, ), label: 'Top', tooltip: 'Top', @@ -196,15 +192,12 @@ class _HomeState extends State { NavigationDestination( icon: Icon( Icons.search_rounded, - color: - _homeStore.selectedIndex == - (_authStore.isLoggedIn ? 2 : 1) - ? theme.colorScheme.onSurface - : theme.colorScheme.onSurfaceVariant, + color: theme.colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), selectedIcon: Icon( Icons.search_rounded, - color: theme.colorScheme.onSurface, + color: theme.colorScheme.primary, ), label: 'Search', tooltip: 'Search', diff --git a/lib/screens/settings/account/widgets/profile_card.dart b/lib/screens/settings/account/widgets/profile_card.dart index 7a55c3a5..b9c5efa7 100644 --- a/lib/screens/settings/account/widgets/profile_card.dart +++ b/lib/screens/settings/account/widgets/profile_card.dart @@ -5,6 +5,7 @@ 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 { @@ -39,12 +40,69 @@ 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(authStore.user.details!.displayName), + title: GestureDetector( + 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'), + ), + ], + ), + ); + }, + child: Text.rich( + TextSpan( + text: authStore.user.details!.displayName, + children: [ + const WidgetSpan(child: SizedBox(width: 6)), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Icon( + hasToken + ? Icons.check_circle_outline_rounded + : Icons.info_outline_rounded, + size: 16, + color: hasToken ? Colors.green : Colors.amber, + ), + ), + ], + ), + overflow: TextOverflow.ellipsis, + ), + ), trailing: const Icon(Icons.chevron_right_rounded), onTap: () => _showAccountOptionsModalBottomSheet(context), ); diff --git a/lib/screens/settings/chat_settings.dart b/lib/screens/settings/chat_settings.dart index 6216ac26..16b086ef 100644 --- a/lib/screens/settings/chat_settings.dart +++ b/lib/screens/settings/chat_settings.dart @@ -1,347 +1,389 @@ -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: 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, - ), - ], - ), - ); - } -} +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 shared chat and ' + 'merged views, 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, + ), + ], + ), + ); + } +} diff --git a/lib/screens/settings/general_settings.dart b/lib/screens/settings/general_settings.dart index e67149f6..6b13f477 100644 --- a/lib/screens/settings/general_settings.dart +++ b/lib/screens/settings/general_settings.dart @@ -25,12 +25,12 @@ class GeneralSettings extends StatelessWidget { AccentColorSetting(settingsStore: settingsStore), const SectionHeader('Stream card'), SettingsListSwitch( - title: 'Use large stream card', + title: 'Large stream cards', value: settingsStore.largeStreamCard, onChanged: (newValue) => settingsStore.largeStreamCard = newValue, ), SettingsListSwitch( - title: 'Show thumbnail', + title: 'Show thumbnails', value: settingsStore.showThumbnails, onChanged: (newValue) => settingsStore.showThumbnails = newValue, ), diff --git a/lib/screens/settings/stores/auth_store.dart b/lib/screens/settings/stores/auth_store.dart index 84d4a2ba..18548635 100644 --- a/lib/screens/settings/stores/auth_store.dart +++ b/lib/screens/settings/stores/auth_store.dart @@ -9,6 +9,7 @@ import 'package:frosty/apis/twitch_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/widgets/frosty_dialog.dart'; import 'package:mobx/mobx.dart'; import 'package:webview_flutter/webview_flutter.dart'; @@ -27,6 +28,9 @@ 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'; + /// The Twitch API service for making requests. final TwitchApi twitchApi; @@ -49,6 +53,10 @@ abstract class AuthBase with Store { @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; @@ -83,6 +91,7 @@ abstract class AuthBase with Store { // 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(); if (routeAfter != null) { navigatorKey.currentState?.pop(); navigatorKey.currentState?.push( @@ -211,12 +220,36 @@ abstract class AuthBase with Store { AuthBase({required this.twitchApi}) : user = UserStore(twitchApi: twitchApi); + 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); + } + /// Initialize by retrieving a token if it does not already exist. @action Future init() async { try { // Read and set the currently stored user token, if any. _token = await _storage.read(key: _userTokenKey); + _gqlToken = await _storage.read(key: _gqlTokenKey); // If the token does not exist, get the default token. // Otherwise, log in. @@ -248,6 +281,10 @@ abstract class AuthBase with Store { if (user.details != null) { _isLoggedIn = true; _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(); } } @@ -293,9 +330,11 @@ abstract class AuthBase with Store { Future logout() async { try { _stopReconnectLoop(); - // Delete the existing user token. + // Delete the existing user token and GQL token. await _storage.delete(key: _userTokenKey); + await _storage.delete(key: _gqlTokenKey); _token = null; + _gqlToken = null; // Clear the user info. user.dispose(); diff --git a/lib/screens/settings/stores/auth_store.g.dart b/lib/screens/settings/stores/auth_store.g.dart index e3f1a04f..1b49807f 100644 --- a/lib/screens/settings/stores/auth_store.g.dart +++ b/lib/screens/settings/stores/auth_store.g.dart @@ -35,6 +35,26 @@ 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, @@ -72,6 +92,18 @@ 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 diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index 0ffe4c16..333f4e38 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -65,9 +65,8 @@ abstract class _SettingsStoreBase with Store { static const defaultShowVideo = true; static const defaultDefaultToHighestQuality = false; static const defaultUseTextureRendering = true; - static const defaultKeepScreenAwake = true; + static const defaultUseNativePlayer = false; - // Overlay defaults static const defaultShowOverlay = true; static const defaultToggleableOverlay = false; static const defaultShowLatency = false; @@ -85,9 +84,9 @@ abstract class _SettingsStoreBase with Store { @observable var useTextureRendering = defaultUseTextureRendering; - @JsonKey(defaultValue: defaultKeepScreenAwake) + @JsonKey(defaultValue: defaultUseNativePlayer) @observable - var keepScreenAwake = defaultKeepScreenAwake; + var useNativePlayer = defaultUseNativePlayer; // Overlay options @JsonKey(defaultValue: defaultShowOverlay) @@ -107,7 +106,7 @@ abstract class _SettingsStoreBase with Store { showVideo = defaultShowVideo; defaultToHighestQuality = defaultDefaultToHighestQuality; useTextureRendering = defaultUseTextureRendering; - keepScreenAwake = defaultKeepScreenAwake; + useNativePlayer = defaultUseNativePlayer; showOverlay = defaultShowOverlay; toggleableOverlay = defaultToggleableOverlay; @@ -115,44 +114,27 @@ abstract class _SettingsStoreBase with Store { } // * Chat Settings - // Message sizing defaults + + // -- Messages -- + // 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; - // Message appearance defaults + // Appearance defaults static const defaultShowDeletedMessages = false; static const defaultShowChatMessageDividers = false; static const defaultTimestampType = TimestampType.disabled; - - // Delay defaults - static const defaultAutoSyncChatDelay = false; - static const defaultChatDelay = 0.0; + static const defaultShowHistoricalTimestamps = false; + static const defaultFocusCurrentChannel = false; // Alert defaults static const defaultHighlightFirstTimeChatter = true; static const defaultShowUserNotices = true; - // 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 + // -- Emotes & Badges -- static const defaultShowTwitchEmotes = true; static const defaultShowTwitchBadges = true; static const defaultShow7TVEmotes = true; @@ -161,14 +143,26 @@ abstract class _SettingsStoreBase with Store { static const defaultShowFFZEmotes = true; static const defaultShowFFZBadges = true; - // Recent messages defaults - static const defaultShowRecentMessages = false; - - // Chat tabs defaults + // -- Layout -- + static const defaultEmoteMenuButtonOnLeft = false; 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; - // Message sizing options + // -- 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 @JsonKey(defaultValue: defaultBadgeScale) @observable var badgeScale = defaultBadgeScale; @@ -189,7 +183,6 @@ abstract class _SettingsStoreBase with Store { @observable var fontSize = defaultFontSize; - // Message appearance options @JsonKey(defaultValue: defaultShowDeletedMessages) @observable var showDeletedMessages = defaultShowDeletedMessages; @@ -205,24 +198,14 @@ abstract class _SettingsStoreBase with Store { @observable var timestampType = defaultTimestampType; - // Delay options - @JsonKey(defaultValue: defaultAutoSyncChatDelay) + @JsonKey(defaultValue: defaultShowHistoricalTimestamps) @observable - var autoSyncChatDelay = defaultAutoSyncChatDelay; + var showHistoricalTimestamps = defaultShowHistoricalTimestamps; - @JsonKey(defaultValue: defaultChatDelay) + @JsonKey(defaultValue: defaultFocusCurrentChannel) @observable - var chatDelay = defaultChatDelay; + var focusCurrentChannel = defaultFocusCurrentChannel; - @JsonKey(includeFromJson: false, includeToJson: false) - @observable - var syncedChatDelay = defaultChatDelay; - - @computed - double get effectiveChatDelay => - autoSyncChatDelay ? syncedChatDelay : chatDelay; - - // Alert options @JsonKey(defaultValue: defaultHighlightFirstTimeChatter) @observable var highlightFirstTimeChatter = defaultHighlightFirstTimeChatter; @@ -231,38 +214,7 @@ abstract class _SettingsStoreBase with Store { @observable var showUserNotices = defaultShowUserNotices; - // 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 + // Emotes & Badges options @JsonKey(defaultValue: defaultShowTwitchEmotes) @observable var showTwitchEmotes = defaultShowTwitchEmotes; @@ -291,12 +243,11 @@ abstract class _SettingsStoreBase with Store { @observable var showFFZBadges = defaultShowFFZBadges; - // Recent messages - @JsonKey(defaultValue: defaultShowRecentMessages) + // Layout options + @JsonKey(defaultValue: defaultEmoteMenuButtonOnLeft) @observable - var showRecentMessages = defaultShowRecentMessages; + var emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft; - // Chat tabs @JsonKey(defaultValue: defaultPersistChatTabs) @observable var persistChatTabs = defaultPersistChatTabs; @@ -305,6 +256,55 @@ 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; @@ -315,36 +315,21 @@ 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; - - autoSyncChatDelay = defaultAutoSyncChatDelay; - chatDelay = defaultChatDelay; - syncedChatDelay = defaultChatDelay; - + showHistoricalTimestamps = defaultShowHistoricalTimestamps; + focusCurrentChannel = defaultFocusCurrentChannel; highlightFirstTimeChatter = defaultHighlightFirstTimeChatter; showUserNotices = defaultShowUserNotices; - emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft; - - landscapeChatLeftSide = defaultLandscapeChatLeftSide; - landscapeForceVerticalChat = defaultLandscapeForceVerticalChat; - landscapeCutout = defaultLandscapeCutout; - chatWidth = defaultChatWidth; - fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity; - - mutedWords = defaultMutedWords; - matchWholeWord = defaultMatchWholeWord; - - autocomplete = defaultAutocomplete; - + // Emotes & Badges showTwitchEmotes = defaultShowTwitchEmotes; showTwitchBadges = defaultShowTwitchBadges; show7TVEmotes = defaultShow7TVEmotes; @@ -353,10 +338,25 @@ abstract class _SettingsStoreBase with Store { showFFZEmotes = defaultShowFFZEmotes; showFFZBadges = defaultShowFFZBadges; - showRecentMessages = defaultShowRecentMessages; - + // Layout + emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft; 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 diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index 1955c3f5..2e713294 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -23,7 +23,7 @@ SettingsStore _$SettingsStoreFromJson( ..showVideo = json['showVideo'] as bool? ?? true ..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false ..useTextureRendering = json['useTextureRendering'] as bool? ?? true - ..keepScreenAwake = json['keepScreenAwake'] as bool? ?? true + ..useNativePlayer = json['useNativePlayer'] as bool? ?? false ..showOverlay = json['showOverlay'] as bool? ?? true ..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false ..showLatency = json['showLatency'] as bool? ?? false @@ -41,12 +41,26 @@ SettingsStore _$SettingsStoreFromJson( unknownValue: TimestampType.disabled, ) ?? TimestampType.disabled - ..autoSyncChatDelay = json['autoSyncChatDelay'] as bool? ?? false - ..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0 + ..showHistoricalTimestamps = + json['showHistoricalTimestamps'] as bool? ?? false + ..focusCurrentChannel = json['focusCurrentChannel'] as bool? ?? false ..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 @@ -59,21 +73,11 @@ 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 - ..persistChatTabs = json['persistChatTabs'] as bool? ?? true - ..secondaryTabs = - (json['secondaryTabs'] as List?) - ?.map((e) => PersistedChatTab.fromJson(e as Map)) - .toList() ?? - [] + ..autoSyncChatDelay = json['autoSyncChatDelay'] as bool? ?? false + ..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0 ..mutedWords = (json['mutedWords'] as List?) ?.map((e) => e as String) @@ -101,7 +105,7 @@ Map _$SettingsStoreToJson( 'showVideo': instance.showVideo, 'defaultToHighestQuality': instance.defaultToHighestQuality, 'useTextureRendering': instance.useTextureRendering, - 'keepScreenAwake': instance.keepScreenAwake, + 'useNativePlayer': instance.useNativePlayer, 'showOverlay': instance.showOverlay, 'toggleableOverlay': instance.toggleableOverlay, 'showLatency': instance.showLatency, @@ -113,17 +117,10 @@ Map _$SettingsStoreToJson( 'showDeletedMessages': instance.showDeletedMessages, 'showChatMessageDividers': instance.showChatMessageDividers, 'timestampType': _$TimestampTypeEnumMap[instance.timestampType]!, - 'autoSyncChatDelay': instance.autoSyncChatDelay, - 'chatDelay': instance.chatDelay, + 'showHistoricalTimestamps': instance.showHistoricalTimestamps, + 'focusCurrentChannel': instance.focusCurrentChannel, '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, @@ -131,9 +128,19 @@ Map _$SettingsStoreToJson( 'showBTTVBadges': instance.showBTTVBadges, 'showFFZEmotes': instance.showFFZEmotes, 'showFFZBadges': instance.showFFZBadges, - 'showRecentMessages': instance.showRecentMessages, + 'emoteMenuButtonOnLeft': instance.emoteMenuButtonOnLeft, '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, @@ -325,21 +332,21 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } - late final _$keepScreenAwakeAtom = Atom( - name: '_SettingsStoreBase.keepScreenAwake', + late final _$useNativePlayerAtom = Atom( + name: '_SettingsStoreBase.useNativePlayer', context: context, ); @override - bool get keepScreenAwake { - _$keepScreenAwakeAtom.reportRead(); - return super.keepScreenAwake; + bool get useNativePlayer { + _$useNativePlayerAtom.reportRead(); + return super.useNativePlayer; } @override - set keepScreenAwake(bool value) { - _$keepScreenAwakeAtom.reportWrite(value, super.keepScreenAwake, () { - super.keepScreenAwake = value; + set useNativePlayer(bool value) { + _$useNativePlayerAtom.reportWrite(value, super.useNativePlayer, () { + super.useNativePlayer = value; }); } @@ -545,57 +552,43 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } - late final _$autoSyncChatDelayAtom = Atom( - name: '_SettingsStoreBase.autoSyncChatDelay', + late final _$showHistoricalTimestampsAtom = Atom( + name: '_SettingsStoreBase.showHistoricalTimestamps', context: context, ); @override - bool get autoSyncChatDelay { - _$autoSyncChatDelayAtom.reportRead(); - return super.autoSyncChatDelay; + bool get showHistoricalTimestamps { + _$showHistoricalTimestampsAtom.reportRead(); + return super.showHistoricalTimestamps; } @override - set autoSyncChatDelay(bool value) { - _$autoSyncChatDelayAtom.reportWrite(value, super.autoSyncChatDelay, () { - super.autoSyncChatDelay = value; - }); + set showHistoricalTimestamps(bool value) { + _$showHistoricalTimestampsAtom.reportWrite( + value, + super.showHistoricalTimestamps, + () { + super.showHistoricalTimestamps = value; + }, + ); } - late final _$chatDelayAtom = Atom( - name: '_SettingsStoreBase.chatDelay', + late final _$focusCurrentChannelAtom = Atom( + name: '_SettingsStoreBase.focusCurrentChannel', context: context, ); @override - double get chatDelay { - _$chatDelayAtom.reportRead(); - return super.chatDelay; + bool get focusCurrentChannel { + _$focusCurrentChannelAtom.reportRead(); + return super.focusCurrentChannel; } @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; + set focusCurrentChannel(bool value) { + _$focusCurrentChannelAtom.reportWrite(value, super.focusCurrentChannel, () { + super.focusCurrentChannel = value; }); } @@ -639,148 +632,6 @@ 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, @@ -907,22 +758,26 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } - late final _$showRecentMessagesAtom = Atom( - name: '_SettingsStoreBase.showRecentMessages', + late final _$emoteMenuButtonOnLeftAtom = Atom( + name: '_SettingsStoreBase.emoteMenuButtonOnLeft', context: context, ); @override - bool get showRecentMessages { - _$showRecentMessagesAtom.reportRead(); - return super.showRecentMessages; + bool get emoteMenuButtonOnLeft { + _$emoteMenuButtonOnLeftAtom.reportRead(); + return super.emoteMenuButtonOnLeft; } @override - set showRecentMessages(bool value) { - _$showRecentMessagesAtom.reportWrite(value, super.showRecentMessages, () { - super.showRecentMessages = value; - }); + set emoteMenuButtonOnLeft(bool value) { + _$emoteMenuButtonOnLeftAtom.reportWrite( + value, + super.emoteMenuButtonOnLeft, + () { + super.emoteMenuButtonOnLeft = value; + }, + ); } late final _$persistChatTabsAtom = Atom( @@ -961,6 +816,216 @@ 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, @@ -1165,7 +1230,7 @@ launchUrlExternal: ${launchUrlExternal}, showVideo: ${showVideo}, defaultToHighestQuality: ${defaultToHighestQuality}, useTextureRendering: ${useTextureRendering}, -keepScreenAwake: ${keepScreenAwake}, +useNativePlayer: ${useNativePlayer}, showOverlay: ${showOverlay}, toggleableOverlay: ${toggleableOverlay}, showLatency: ${showLatency}, @@ -1177,18 +1242,10 @@ fontSize: ${fontSize}, showDeletedMessages: ${showDeletedMessages}, showChatMessageDividers: ${showChatMessageDividers}, timestampType: ${timestampType}, -autoSyncChatDelay: ${autoSyncChatDelay}, -chatDelay: ${chatDelay}, -syncedChatDelay: ${syncedChatDelay}, +showHistoricalTimestamps: ${showHistoricalTimestamps}, +focusCurrentChannel: ${focusCurrentChannel}, highlightFirstTimeChatter: ${highlightFirstTimeChatter}, showUserNotices: ${showUserNotices}, -emoteMenuButtonOnLeft: ${emoteMenuButtonOnLeft}, -landscapeChatLeftSide: ${landscapeChatLeftSide}, -landscapeForceVerticalChat: ${landscapeForceVerticalChat}, -landscapeCutout: ${landscapeCutout}, -chatWidth: ${chatWidth}, -fullScreenChatOverlayOpacity: ${fullScreenChatOverlayOpacity}, -autocomplete: ${autocomplete}, showTwitchEmotes: ${showTwitchEmotes}, showTwitchBadges: ${showTwitchBadges}, show7TVEmotes: ${show7TVEmotes}, @@ -1196,9 +1253,20 @@ showBTTVEmotes: ${showBTTVEmotes}, showBTTVBadges: ${showBTTVBadges}, showFFZEmotes: ${showFFZEmotes}, showFFZBadges: ${showFFZBadges}, -showRecentMessages: ${showRecentMessages}, +emoteMenuButtonOnLeft: ${emoteMenuButtonOnLeft}, 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}, diff --git a/lib/screens/settings/video_settings.dart b/lib/screens/settings/video_settings.dart index d6b2b72b..4e2aedd4 100644 --- a/lib/screens/settings/video_settings.dart +++ b/lib/screens/settings/video_settings.dart @@ -20,10 +20,20 @@ class VideoSettings extends StatelessWidget { children: [ const SectionHeader('Player', isFirst: true), SettingsListSwitch( - title: 'Enable video', + title: 'Show video player', value: settingsStore.showVideo, onChanged: (newValue) => settingsStore.showVideo = newValue, ), + if (settingsStore.showVideo) + SettingsListSwitch( + title: 'Native player (experimental)', + subtitle: const Text( + 'More performant video player with auto picture-in-picture and quality selection.', + ), + value: settingsStore.useNativePlayer, + onChanged: (newValue) => + settingsStore.useNativePlayer = newValue, + ), if (!Platform.isIOS || isIPad()) SettingsListSwitch( title: 'Default to highest quality', @@ -41,14 +51,6 @@ class VideoSettings extends StatelessWidget { onChanged: (newValue) => settingsStore.useTextureRendering = newValue, ), - SettingsListSwitch( - title: 'Keep screen awake', - subtitle: const Text( - 'Prevents the screen from sleeping while a channel is open.', - ), - value: settingsStore.keepScreenAwake, - onChanged: (newValue) => settingsStore.keepScreenAwake = newValue, - ), const SectionHeader('Overlay'), SettingsListSwitch( title: 'Use custom video overlay', @@ -59,12 +61,13 @@ class VideoSettings extends StatelessWidget { onChanged: (newValue) => settingsStore.showOverlay = newValue, ), SettingsListSwitch( - title: 'Long-press player to toggle overlay', + title: 'Toggle overlay on long-press', subtitle: const Text( - 'Allows switching between Twitch\'s overlay and the custom overlay.', + 'Switch 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/services/cookie_extractor.dart b/lib/services/cookie_extractor.dart new file mode 100644 index 00000000..cca2b028 --- /dev/null +++ b/lib/services/cookie_extractor.dart @@ -0,0 +1,9 @@ +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/theme.dart b/lib/theme.dart index 06216a11..19eb4f80 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -39,23 +39,23 @@ class FrostyColors extends ThemeExtension { required this.overlayOnSurface, }); - static const light = FrostyColors( - highlightedMessage: Color(0xff9146ff), - emoteOnly: Color(0xFFFFB74D), - followersOnly: Color(0xFFF44336), - slowMode: Color(0xFF2196F3), - subOnly: Color(0xFF4CAF50), - uniqueMode: Color(0xFFAB47BC), + 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 const dark = FrostyColors( - highlightedMessage: Color(0xff9146ff), - emoteOnly: Color(0xFFFFCC80), - followersOnly: Color(0xFFE57373), - slowMode: Color(0xFF64B5F6), - subOnly: Color(0xFF81C784), - uniqueMode: Color(0xFFBA68C8), + 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, ); @@ -124,12 +124,17 @@ class FrostyThemes { return ThemeData( extensions: [ colorScheme.brightness == Brightness.dark - ? FrostyColors.dark - : FrostyColors.light, + ? 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, @@ -139,7 +144,7 @@ class FrostyThemes { menuTheme: MenuThemeData( style: MenuStyle( padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric(vertical: 4), + EdgeInsets.symmetric(vertical: 2), ), shape: WidgetStatePropertyAll( RoundedRectangleBorder( @@ -152,6 +157,9 @@ class FrostyThemes { menuButtonTheme: MenuButtonThemeData( style: MenuItemButton.styleFrom( visualDensity: VisualDensity.compact, + minimumSize: const Size(0, 40), + padding: const EdgeInsets.symmetric(horizontal: 12), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ), dialogTheme: DialogThemeData( @@ -196,6 +204,23 @@ 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 f9cf83bf..f666821a 100644 --- a/lib/utils/context_extensions.dart +++ b/lib/utils/context_extensions.dart @@ -3,6 +3,7 @@ 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'; @@ -13,17 +14,17 @@ import 'package:provider/provider.dart'; class OrientationUtils { /// Gets the current orientation from the given context static Orientation getCurrentOrientation(BuildContext context) { - return MediaQuery.of(context).orientation; + return MediaQuery.orientationOf(context); } /// Returns true if the current orientation is portrait static bool isPortrait(BuildContext context) { - return MediaQuery.of(context).orientation == Orientation.portrait; + return MediaQuery.orientationOf(context) == Orientation.portrait; } /// Returns true if the current orientation is landscape static bool isLandscape(BuildContext context) { - return MediaQuery.of(context).orientation == Orientation.landscape; + return MediaQuery.orientationOf(context) == Orientation.landscape; } } @@ -55,22 +56,25 @@ extension ContextExtensions on BuildContext { // ===== SCREEN & LAYOUT ===== /// Gets the screen width - double get screenWidth => MediaQuery.of(this).size.width; + double get screenWidth => MediaQuery.sizeOf(this).width; /// Gets the screen height - double get screenHeight => MediaQuery.of(this).size.height; + double get screenHeight => MediaQuery.sizeOf(this).height; /// Gets the safe area padding top (commonly used) - double get safePaddingTop => MediaQuery.of(this).padding.top; + double get safePaddingTop => MediaQuery.paddingOf(this).top; /// Gets the safe area padding bottom (commonly used) - double get safePaddingBottom => MediaQuery.of(this).padding.bottom; + double get safePaddingBottom => MediaQuery.paddingOf(this).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/widgets/expandable_section_header.dart b/lib/widgets/expandable_section_header.dart index 7e3dcb78..1c6eb8a0 100644 --- a/lib/widgets/expandable_section_header.dart +++ b/lib/widgets/expandable_section_header.dart @@ -49,10 +49,10 @@ class ExpandableSectionHeader extends StatelessWidget { child: Text( text.toUpperCase(), style: TextStyle( - fontSize: fontSize ?? 13, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - color: context.colorScheme.onSurface.withValues( + fontSize: fontSize ?? 12, + fontWeight: FontWeight.w500, + letterSpacing: 0.8, + color: context.colorScheme.onSurfaceVariant.withValues( alpha: 0.6, ), ), @@ -63,7 +63,9 @@ class ExpandableSectionHeader extends StatelessWidget { duration: const Duration(milliseconds: 200), child: Icon( Icons.keyboard_arrow_down, - color: context.colorScheme.onSurfaceVariant, + color: context.colorScheme.onSurfaceVariant.withValues( + alpha: 0.6, + ), size: 20, ), ), diff --git a/lib/widgets/live_indicator.dart b/lib/widgets/live_indicator.dart index cb6ce460..3ef42f18 100644 --- a/lib/widgets/live_indicator.dart +++ b/lib/widgets/live_indicator.dart @@ -138,57 +138,61 @@ class _LiveIndicatorState extends State ); } - 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, + 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, ), - 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/measure_size.dart b/lib/widgets/measure_size.dart new file mode 100644 index 00000000..b7cba10d --- /dev/null +++ b/lib/widgets/measure_size.dart @@ -0,0 +1,46 @@ +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 3768428e..8580d66a 100644 --- a/lib/widgets/profile_picture.dart +++ b/lib/widgets/profile_picture.dart @@ -7,7 +7,11 @@ 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 285244a6..40d4855a 100644 --- a/lib/widgets/section_header.dart +++ b/lib/widgets/section_header.dart @@ -36,10 +36,12 @@ class SectionHeader extends StatelessWidget { Text( text.toUpperCase(), style: TextStyle( - fontSize: fontSize ?? 13, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - color: context.colorScheme.onSurfaceVariant, + fontSize: fontSize ?? 12, + fontWeight: FontWeight.w500, + letterSpacing: 0.8, + color: context.colorScheme.onSurfaceVariant.withValues( + alpha: 0.6, + ), ), ), ], diff --git a/lib/widgets/settings_page_layout.dart b/lib/widgets/settings_page_layout.dart index 9b6ebd31..67041760 100644 --- a/lib/widgets/settings_page_layout.dart +++ b/lib/widgets/settings_page_layout.dart @@ -39,7 +39,7 @@ class _SettingsPageLayoutState extends State { // Responsive values based on orientation final listPadding = EdgeInsets.only( - top: isPortrait ? 116 : 92, + top: isPortrait ? 124 : 100, bottom: widget.hasBottomPadding ? context.safePaddingBottom + 8 : 0, ); diff --git a/pubspec.lock b/pubspec.lock index 11a70b52..a2e24717 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,1500 +1,1533 @@ -# 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: afe15ce18a287d2f89da95566e62892df339b1936bbe9b83587df45b944ee72a - url: "https://pub.dev" - source: hosted - version: "1.3.67" - 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: 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: "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: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" - url: "https://pub.dev" - source: hosted - version: "1.3.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: "7981eb922842c77033026eb4341d5af651562008cdb116bdfa31fc46516b6462" - url: "https://pub.dev" - source: hosted - version: "2.12.2" - 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: "6ae8a6435a8c6520c7077b107e77f1fb4ba7009633259a4d49a8afd8e7efc5e9" - url: "https://pub.dev" - source: hosted - version: "8.12.4" - 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: "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: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c - url: "https://pub.dev" - source: hosted - version: "5.9.2" - dio_web_adapter: - dependency: transitive - description: - name: dio_web_adapter - sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" - url: "https://pub.dev" - source: hosted - version: "2.1.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: "8170273394694efdf567e7e30c26457ff346377a52eb679c278f97b36b786d70" - url: "https://pub.dev" - source: hosted - version: "12.1.3" - firebase_analytics_platform_interface: - dependency: transitive - description: - name: firebase_analytics_platform_interface - sha256: "2c25cd85f640a47fcc15e970a04a50470f0a4d0e76f23c5bc5ec93a3d48d8775" - url: "https://pub.dev" - source: hosted - version: "5.0.7" - firebase_analytics_web: - dependency: transitive - description: - name: firebase_analytics_web - sha256: e0eb2b21eccb18109af2c1d3154f9e69b45abf4816e3290a6251008e0503aca9 - url: "https://pub.dev" - source: hosted - version: "0.6.1+3" - firebase_core: - dependency: "direct main" - description: - name: firebase_core - sha256: f0997fee80fbb6d2c658c5b88ae87ba1f9506b5b37126db64fc2e75d8e977fbb - url: "https://pub.dev" - source: hosted - version: "4.5.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: "856ca92bf2d75a63761286ab8e791bda3a85184c2b641764433b619647acfca6" - url: "https://pub.dev" - source: hosted - version: "3.5.0" - firebase_crashlytics: - dependency: "direct main" - description: - name: firebase_crashlytics - sha256: "2a6dc88d762af01790a05ff0cf814f7d4020050e8c69dec01962d9ed5dc1a531" - url: "https://pub.dev" - source: hosted - version: "5.0.8" - firebase_crashlytics_platform_interface: - dependency: transitive - description: - name: firebase_crashlytics_platform_interface - sha256: "5fd59d76d691f370e42fd2b786d46078e69ed4126ca0d84b585119f55cd97937" - url: "https://pub.dev" - source: hosted - version: "3.8.18" - firebase_performance: - dependency: "direct main" - description: - name: firebase_performance - sha256: a372e9479b5be725cd6ef62488afc07e1afd9a66627096a0f9d8b59c0ed3ed0d - url: "https://pub.dev" - source: hosted - version: "0.11.1+5" - firebase_performance_platform_interface: - dependency: transitive - description: - name: firebase_performance_platform_interface - sha256: c8be195a97ae9dfe13f422c02edc9e4ef2fe16476bb182b65562afb0729ce29b - url: "https://pub.dev" - source: hosted - version: "0.1.6+5" - firebase_performance_web: - dependency: transitive - description: - name: firebase_performance_web - sha256: "04d8285e5ff3aafa4f5ab9176fc0c48776a4b2df5bc26f31b91f8cc3ab1de526" - url: "https://pub.dev" - source: hosted - version: "0.1.8+3" - 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: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9" - url: "https://pub.dev" - source: hosted - version: "2.2.4" - 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: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 - url: "https://pub.dev" - source: hosted - version: "1.0.2" - 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: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce - url: "https://pub.dev" - source: hosted - version: "4.8.0" - 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: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 - url: "https://pub.dev" - source: hosted - version: "4.11.0" - json_serializable: - dependency: "direct dev" - description: - name: json_serializable - sha256: "44729f5c45748e6748f6b9a57ab8f7e4336edc8ae41fc295070e3814e616a6c0" - url: "https://pub.dev" - source: hosted - version: "6.13.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: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 - url: "https://pub.dev" - source: hosted - version: "0.12.19" - 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: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" - url: "https://pub.dev" - source: hosted - version: "1.18.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: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f" - url: "https://pub.dev" - source: hosted - version: "0.17.5" - 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: "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: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" - url: "https://pub.dev" - source: hosted - version: "6.5.0" - 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" - 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: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" - url: "https://pub.dev" - source: hosted - version: "2.4.21" - 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: "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: "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: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" - url: "https://pub.dev" - source: hosted - version: "0.7.11" - 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: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" - url: "https://pub.dev" - source: hosted - version: "6.3.28" - 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: 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: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" - url: "https://pub.dev" - source: hosted - version: "4.5.3" - 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: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - 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: "2a03df01df2fd30b075d1e7f24c28aee593f2e5d5ac4c3c4283c5eda63717b24" - url: "https://pub.dev" - source: hosted - version: "4.10.13" - 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: fc0af89d403e1c053f03d023d97550412fa79f35332e2939514c82e6fe633198 - url: "https://pub.dev" - source: hosted - version: "3.23.8" - 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" +# 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: "0d1f0adfabbab9f46a1a80ce84a4d8b852b6e4dbf53ce413b30e0cf7d3631b71" + url: "https://pub.dev" + source: hosted + version: "1.3.72" + 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: a350a5b37579b7227aaf9a59c07114617cd4283852e193f743b2b3d2d7483c18 + url: "https://pub.dev" + source: hosted + version: "7.1.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: "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: 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: "6460859318b7b44034fe44016cddff02d0bd4c0a" + 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: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 + url: "https://pub.dev" + source: hosted + version: "4.0.6" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + url: "https://pub.dev" + source: hosted + version: "1.3.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: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" + url: "https://pub.dev" + source: hosted + version: "2.15.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: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" + url: "https://pub.dev" + source: hosted + version: "8.12.6" + 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: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + url: "https://pub.dev" + source: hosted + version: "5.9.2" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + 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: "1f68d8a30265149035e9bb0b73962e43f3bf2debef4a7ae2d1d588361d5858a9" + url: "https://pub.dev" + source: hosted + version: "12.4.2" + firebase_analytics_platform_interface: + dependency: transitive + description: + name: firebase_analytics_platform_interface + sha256: "65f97fb923f036d487aa95be99eab90e0f8f636e4783c94deeb52c0e6f5dbae7" + url: "https://pub.dev" + source: hosted + version: "6.0.2" + firebase_analytics_web: + dependency: transitive + description: + name: firebase_analytics_web + sha256: ec49a95c6abaadbd6a4327cc2680d911f86e62fdf6f2c96a3a326603410e2abf + url: "https://pub.dev" + source: hosted + version: "0.6.1+8" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: ec46a100a560d3bd5f97f2d89ba7492cb09b6dd0a4a28753d1258f360d6bd9f9 + url: "https://pub.dev" + source: hosted + version: "4.10.0" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: "4a120366dbf7d5a8ee9438978530b664b855728fb8dcc3a201017660817e555b" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: "5ad1be848692ec148f2d6a8ad2a3838cb852ea5f3c9e6479a7afce479e1854f8" + url: "https://pub.dev" + source: hosted + version: "3.8.0" + firebase_crashlytics: + dependency: "direct main" + description: + name: firebase_crashlytics + sha256: "5d111db2b40426e76e2da95d133c19a9dbcf6500c4e9b3785c68da51fb3f6602" + url: "https://pub.dev" + source: hosted + version: "5.2.3" + firebase_crashlytics_platform_interface: + dependency: transitive + description: + name: firebase_crashlytics_platform_interface + sha256: "5bf4a83a1b9e0a5218f6d6491227440dd659242a55b16c279de0b8839791539a" + url: "https://pub.dev" + source: hosted + version: "3.8.23" + firebase_performance: + dependency: "direct main" + description: + name: firebase_performance + sha256: a6b88d6933e02e4ba71f0641121dd7024f036b32a9929a01b638b3696a98e9ce + url: "https://pub.dev" + source: hosted + version: "0.11.4+2" + firebase_performance_platform_interface: + dependency: transitive + description: + name: firebase_performance_platform_interface + sha256: "6b9912709d315f4f2e0c87ddada8fe4f4d1b06aa74df52a8ced19fed087f0591" + url: "https://pub.dev" + source: hosted + version: "0.2.0+2" + firebase_performance_web: + dependency: transitive + description: + name: firebase_performance_web + sha256: "5ccf0a4511cd477406fe4c02ee2cabee5bd7d3c9e8cc5effdba5dcbd46b90064" + url: "https://pub.dev" + source: hosted + version: "0.1.8+8" + 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: "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: "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: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + 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: "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" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + 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: ffcd10cde35a93b2abbbcc26bd9971f4ca93763e8abe78d855e3c4177797e501 + url: "https://pub.dev" + source: hosted + version: "6.14.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: "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: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + 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: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.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: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.dev" + source: hosted + version: "9.4.1" + 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: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + 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: 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: "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: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + 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: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.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: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5" + url: "https://pub.dev" + source: hosted + version: "2.4.26" + 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: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 + url: "https://pub.dev" + source: hosted + version: "4.2.3" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae" + url: "https://pub.dev" + source: hosted + version: "1.3.12" + 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: cce558075afe2a83f3fd7fc123acd6b090683e4f23910d44fbb31ecd7800b014 + url: "https://pub.dev" + source: hosted + version: "2.5.9" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "164a5d73ab87a134566057219988bafde837029a64264e61f1f04376ef3cfcd2" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + 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: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + 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: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + 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: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + 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: "7ee12e6dffe0fc8e755179d6d91b3b34f5924223fc104d85572ef9180d73d172" + url: "https://pub.dev" + source: hosted + version: "1.2.5" + 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: "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: b13f99e992e7ae6a152e16c5559d3c07ff445b13330192662494e614ca3e7d7b + url: "https://pub.dev" + source: hosted + version: "1.5.1" + 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: a97db7a44f8e71af2f3971c45550a08cce1fb60059c1b8e534251e6cfb753490 + url: "https://pub.dev" + source: hosted + version: "4.13.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.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index f5f69184..6ecbaa06 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -76,6 +76,10 @@ dependencies: 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: diff --git a/test/models/playback_access_token_test.dart b/test/models/playback_access_token_test.dart new file mode 100644 index 00000000..fb09a3fa --- /dev/null +++ b/test/models/playback_access_token_test.dart @@ -0,0 +1,78 @@ +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/video/chat_latency_sync_test.dart b/test/screens/channel/video/chat_latency_sync_test.dart new file mode 100644 index 00000000..6d9a49fb --- /dev/null +++ b/test/screens/channel/video/chat_latency_sync_test.dart @@ -0,0 +1,111 @@ +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/stream_info_poller_test.dart b/test/screens/channel/video/stream_info_poller_test.dart new file mode 100644 index 00000000..935a98ad --- /dev/null +++ b/test/screens/channel/video/stream_info_poller_test.dart @@ -0,0 +1,194 @@ +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 new file mode 100644 index 00000000..9632ee94 --- /dev/null +++ b/test/screens/channel/video/video_timing_constants_test.dart @@ -0,0 +1,159 @@ +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/web/.gitignore b/web/.gitignore index a399f275..f51043ee 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -45,3 +45,4 @@ next-env.d.ts # ai .claude/settings.local.json +.playwright-mcp diff --git a/web/AGENTS.md b/web/AGENTS.md index a53f397c..7926777d 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -1,5 +1,19 @@ # 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 @@ -17,25 +31,25 @@ pnpm clean # Delete .next, .open-next, and node_modules pnpm nuke # Delete .next, .open-next, node_modules, and pnpm-lock.yaml ``` -## Architecture - -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 - -- **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` - -### Source Structure +## Source Structure - `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 +## 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 + +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. + +## Cloudflare Workers - `wrangler.jsonc` - Cloudflare Workers configuration (bindings, R2, KV, etc.) - `open-next.config.ts` - OpenNext adapter config (ISR requires uncommenting R2 incremental cache) @@ -67,9 +81,12 @@ Notable strict rules enforced: - `react-you-might-not-need-an-effect` - Avoid unnecessary useEffect - Unused variables must be prefixed with `_` -## Commits +## Gotchas -Lowercase, no prefixes (e.g., `remove marquee emote background from hero section`). Keep commits tightly scoped. +- **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`). ## Dev Tooling diff --git a/web/package.json b/web/package.json index 97f062ab..64276b4f 100644 --- a/web/package.json +++ b/web/package.json @@ -36,7 +36,6 @@ "@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", @@ -50,7 +49,6 @@ "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 1fe8ba8e..9e80f6cb 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -60,9 +60,6 @@ importers: '@tailwindcss/postcss': specifier: ^4.1.18 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.2.3 @@ -102,9 +99,6 @@ importers: prettier-plugin-tailwindcss: specifier: ^0.7.2 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.2.3)(typescript@5.9.3) @@ -612,12 +606,6 @@ packages: '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} - '@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==} engines: {node: '>=18'} @@ -630,12 +618,6 @@ 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'} @@ -648,12 +630,6 @@ 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'} @@ -666,12 +642,6 @@ 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'} @@ -684,12 +654,6 @@ 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'} @@ -702,12 +666,6 @@ 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'} @@ -720,12 +678,6 @@ 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'} @@ -738,12 +690,6 @@ 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'} @@ -756,12 +702,6 @@ 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'} @@ -774,12 +714,6 @@ 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'} @@ -792,12 +726,6 @@ 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'} @@ -810,12 +738,6 @@ 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'} @@ -828,12 +750,6 @@ 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'} @@ -846,12 +762,6 @@ 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'} @@ -864,12 +774,6 @@ 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'} @@ -882,12 +786,6 @@ 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'} @@ -900,12 +798,6 @@ 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'} @@ -918,12 +810,6 @@ 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'} @@ -936,12 +822,6 @@ 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'} @@ -954,12 +834,6 @@ 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'} @@ -972,12 +846,6 @@ 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'} @@ -990,24 +858,12 @@ 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'} @@ -1020,12 +876,6 @@ 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'} @@ -1038,12 +888,6 @@ 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'} @@ -1056,12 +900,6 @@ 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'} @@ -1546,23 +1384,6 @@ 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==} @@ -1887,11 +1708,6 @@ packages: '@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==} @@ -1916,9 +1732,6 @@ packages: '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} - '@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==} @@ -1927,11 +1740,6 @@ packages: peerDependencies: '@types/react': ^19.2.0 - '@types/react-reconciler@0.28.9': - resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==} - peerDependencies: - '@types/react': '*' - '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} @@ -2288,11 +2096,6 @@ packages: 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==} @@ -2658,11 +2461,6 @@ 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'} @@ -2859,12 +2657,6 @@ 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'} @@ -3979,10 +3771,6 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - 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'} @@ -3999,9 +3787,6 @@ packages: 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'} @@ -4123,13 +3908,6 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - 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'} @@ -4547,9 +4325,6 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -4576,10 +4351,6 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - 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==} @@ -4625,9 +4396,6 @@ 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==} @@ -5816,231 +5584,153 @@ snapshots: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.25.12': - optional: true - '@esbuild/aix-ppc64@0.25.4': optional: true '@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 @@ -6546,19 +6236,6 @@ 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': {} @@ -6980,11 +6657,6 @@ snapshots: 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 @@ -7013,10 +6685,6 @@ snapshots: dependencies: undici-types: 5.26.5 - '@types/node@20.19.33': - dependencies: - undici-types: 6.21.0 - '@types/node@25.2.3': dependencies: undici-types: 7.16.0 @@ -7025,10 +6693,6 @@ snapshots: 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 @@ -7417,13 +7081,6 @@ snapshots: 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: {} blake3-wasm@2.1.5: {} @@ -7824,35 +7481,6 @@ 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 @@ -8233,12 +7861,6 @@ 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: {} @@ -9323,11 +8945,6 @@ snapshots: possible-typed-array-names@1.1.0: {} - 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 @@ -9347,8 +8964,6 @@ snapshots: powershell-utils@0.1.0: {} - preact@10.28.3: {} - prelude-ls@1.2.1: {} prettier-plugin-organize-imports@4.3.0(prettier@3.8.1)(typescript@5.9.3): @@ -9412,30 +9027,6 @@ snapshots: react-is@16.13.1: {} - 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: @@ -9982,8 +9573,6 @@ snapshots: undici-types@5.26.5: {} - undici-types@6.21.0: {} - undici-types@7.16.0: {} undici@7.16.0: {} @@ -10000,12 +9589,6 @@ snapshots: unpipe@1.0.0: {} - 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 @@ -10060,9 +9643,6 @@ snapshots: webidl-conversions@3.0.1: {} - webpack-virtual-modules@0.6.2: - optional: true - whatwg-url@5.0.0: dependencies: tr46: 0.0.3 diff --git a/web/public/_headers b/web/public/_headers index 5392acf8..c846ea71 100644 --- a/web/public/_headers +++ b/web/public/_headers @@ -1,8 +1,2 @@ /_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 63eec7d0..e01ee0cb 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -2,8 +2,6 @@ @import 'tw-animate-css'; @import 'shadcn/tailwind.css'; -@plugin '@tailwindcss/typography'; - @custom-variant dark (&:where(.dark, .dark *)); @theme inline { diff --git a/web/src/components/Providers.tsx b/web/src/components/Providers.tsx index 8f9612c6..617d32c7 100644 --- a/web/src/components/Providers.tsx +++ b/web/src/components/Providers.tsx @@ -3,21 +3,12 @@ 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 4dd24a14..348d12cb 100644 --- a/web/wrangler.jsonc +++ b/web/wrangler.jsonc @@ -29,11 +29,6 @@ // "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,