feat(video,chat): native player, ad-break awareness, chat send/delay reliability, and tab redesign (#567)
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)
This commit is contained in:
parent
8883af90f0
commit
f2b932bc93
72 changed files with 7220 additions and 3816 deletions
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
38
AGENTS.md
38
AGENTS.md
|
|
@ -1,12 +1,29 @@
|
||||||
# AGENTS.md
|
# 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
|
## Code Opinions
|
||||||
- When new code supersedes existing functionality, find and remove everything it makes redundant
|
|
||||||
- Favor parallel tool calls and subagents when tasks are independent
|
- 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
|
## Commands
|
||||||
|
|
||||||
|
|
@ -29,7 +46,7 @@ dart run build_runner build --delete-conflicting-outputs # Same, but
|
||||||
- `lib/widgets/` — Shared widgets
|
- `lib/widgets/` — Shared widgets
|
||||||
- `lib/utils/` — Utility functions
|
- `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)
|
- 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
|
- 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.
|
- 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.
|
- 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
|
## 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`
|
- HTTP mocking: `http_mock_adapter` (`DioAdapter`) — use full URLs in `onGet`/`onPost`
|
||||||
- General mocking: `mocktail` (no codegen required)
|
- General mocking: `mocktail` (no codegen required)
|
||||||
- Fixtures live in `test/fixtures/` (e.g., `irc_messages.dart`, `api_responses.dart`)
|
- 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`).
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
package com.tommychow.frosty
|
package com.tommychow.frosty
|
||||||
|
|
||||||
import cl.puntito.simple_pip_mode.PipCallbackHelperActivityWrapper
|
import cl.puntito.simple_pip_mode.PipCallbackHelperActivityWrapper
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
|
||||||
class MainActivity: PipCallbackHelperActivityWrapper() {
|
class MainActivity: PipCallbackHelperActivityWrapper() {
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
flutterEngine.plugins.add(CookieExtractorPlugin())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
org.gradle.caching=true
|
org.gradle.caching=true
|
||||||
org.gradle.parallel=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
|
||||||
|
|
|
||||||
287
ios/Podfile.lock
287
ios/Podfile.lock
|
|
@ -1,314 +1,27 @@
|
||||||
PODS:
|
PODS:
|
||||||
- advanced_in_app_review (0.0.1):
|
- advanced_in_app_review (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- app_links (7.0.0):
|
|
||||||
- Flutter
|
|
||||||
- device_info_plus (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- Firebase/CoreOnly (12.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 (1.0.0)
|
||||||
- flutter_native_splash (2.4.3):
|
|
||||||
- Flutter
|
|
||||||
- flutter_secure_storage (6.0.0):
|
- flutter_secure_storage (6.0.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
- GoogleAdsOnDeviceConversion (3.2.0):
|
|
||||||
- GoogleUtilities/Environment (~> 8.1)
|
|
||||||
- GoogleUtilities/Logger (~> 8.1)
|
|
||||||
- GoogleUtilities/Network (~> 8.1)
|
|
||||||
- nanopb (~> 3.30910.0)
|
|
||||||
- GoogleAppMeasurement/Core (12.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:
|
DEPENDENCIES:
|
||||||
- advanced_in_app_review (from `.symlinks/plugins/advanced_in_app_review/ios`)
|
- advanced_in_app_review (from `.symlinks/plugins/advanced_in_app_review/ios`)
|
||||||
- app_links (from `.symlinks/plugins/app_links/ios`)
|
|
||||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
|
||||||
- firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
|
|
||||||
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
|
|
||||||
- firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`)
|
|
||||||
- firebase_performance (from `.symlinks/plugins/firebase_performance/ios`)
|
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
|
||||||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||||
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
|
|
||||||
- native_device_orientation (from `.symlinks/plugins/native_device_orientation/ios`)
|
|
||||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
|
||||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
|
||||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
|
||||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
|
||||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
|
||||||
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
|
|
||||||
|
|
||||||
SPEC REPOS:
|
|
||||||
trunk:
|
|
||||||
- Firebase
|
|
||||||
- FirebaseABTesting
|
|
||||||
- FirebaseAnalytics
|
|
||||||
- FirebaseCore
|
|
||||||
- FirebaseCoreExtension
|
|
||||||
- FirebaseCoreInternal
|
|
||||||
- FirebaseCrashlytics
|
|
||||||
- FirebaseInstallations
|
|
||||||
- FirebasePerformance
|
|
||||||
- FirebaseRemoteConfig
|
|
||||||
- FirebaseRemoteConfigInterop
|
|
||||||
- FirebaseSessions
|
|
||||||
- FirebaseSharedSwift
|
|
||||||
- GoogleAdsOnDeviceConversion
|
|
||||||
- GoogleAppMeasurement
|
|
||||||
- GoogleDataTransport
|
|
||||||
- GoogleUtilities
|
|
||||||
- nanopb
|
|
||||||
- PromisesObjC
|
|
||||||
- PromisesSwift
|
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
advanced_in_app_review:
|
advanced_in_app_review:
|
||||||
:path: ".symlinks/plugins/advanced_in_app_review/ios"
|
:path: ".symlinks/plugins/advanced_in_app_review/ios"
|
||||||
app_links:
|
|
||||||
:path: ".symlinks/plugins/app_links/ios"
|
|
||||||
device_info_plus:
|
|
||||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
|
||||||
firebase_analytics:
|
|
||||||
:path: ".symlinks/plugins/firebase_analytics/ios"
|
|
||||||
firebase_core:
|
|
||||||
:path: ".symlinks/plugins/firebase_core/ios"
|
|
||||||
firebase_crashlytics:
|
|
||||||
:path: ".symlinks/plugins/firebase_crashlytics/ios"
|
|
||||||
firebase_performance:
|
|
||||||
:path: ".symlinks/plugins/firebase_performance/ios"
|
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
flutter_native_splash:
|
|
||||||
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
|
||||||
flutter_secure_storage:
|
flutter_secure_storage:
|
||||||
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
||||||
in_app_review:
|
|
||||||
:path: ".symlinks/plugins/in_app_review/ios"
|
|
||||||
native_device_orientation:
|
|
||||||
:path: ".symlinks/plugins/native_device_orientation/ios"
|
|
||||||
package_info_plus:
|
|
||||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
|
||||||
shared_preferences_foundation:
|
|
||||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
|
||||||
sqflite_darwin:
|
|
||||||
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
|
||||||
url_launcher_ios:
|
|
||||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
|
||||||
wakelock_plus:
|
|
||||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
|
||||||
webview_flutter_wkwebview:
|
|
||||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
advanced_in_app_review: fe8fd50b8670cb3dbe67e4c26ca643f7aea8dbb8
|
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: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
|
|
||||||
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
|
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
|
PODFILE CHECKSUM: 57c8aed26fba39d3ec9424816221f294a07c58eb
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,13 @@
|
||||||
3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E751BC99755AAC391DC0B29F /* Pods_Runner.framework */; };
|
3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E751BC99755AAC391DC0B29F /* Pods_Runner.framework */; };
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
|
F1C00CE01A2B3C4D00E5F6A8 /* CookieExtractorPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */; };
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
BE8095795EAFF58C08A43B48 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A68609F67C30CFEF267CD4D3 /* GoogleService-Info.plist */; };
|
BE8095795EAFF58C08A43B48 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A68609F67C30CFEF267CD4D3 /* GoogleService-Info.plist */; };
|
||||||
D4F5A60712340001ABCD1234 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */; };
|
D4F5A60712340001ABCD1234 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */; };
|
||||||
|
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
|
@ -38,6 +40,7 @@
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieExtractorPlugin.swift; sourceTree = "<group>"; };
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||||
95D7E205EE04D01F6996ECFC /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
95D7E205EE04D01F6996ECFC /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
|
|
@ -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 = "<group>"; };
|
CFF3F6A0DA5A835B219C2D3C /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||||
E751BC99755AAC391DC0B29F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
E751BC99755AAC391DC0B29F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
|
@ -58,6 +62,7 @@
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||||
3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */,
|
3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|
@ -68,6 +73,7 @@
|
||||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||||
|
|
@ -106,6 +112,7 @@
|
||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||||
|
F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */,
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||||
D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */,
|
D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */,
|
||||||
);
|
);
|
||||||
|
|
@ -134,6 +141,9 @@
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||||
|
packageProductDependencies = (
|
||||||
|
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||||
|
);
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
|
@ -160,6 +170,9 @@
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
97C146E61CF9000F007C117D /* Project object */ = {
|
97C146E61CF9000F007C117D /* Project object */ = {
|
||||||
|
packageReferences = (
|
||||||
|
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
||||||
|
);
|
||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
LastUpgradeCheck = 1510;
|
LastUpgradeCheck = 1510;
|
||||||
|
|
@ -246,7 +259,7 @@
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
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 */ = {
|
34CC65D58A1948B306772884 /* [CP] Embed Pods Frameworks */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
|
@ -304,6 +317,7 @@
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||||
|
F1C00CE01A2B3C4D00E5F6A8 /* CookieExtractorPlugin.swift in Sources */,
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|
@ -590,6 +604,18 @@
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||||
|
};
|
||||||
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = FlutterGeneratedPluginSwiftPackage;
|
||||||
|
};
|
||||||
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,24 @@
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES">
|
buildImplicitDependencies = "YES">
|
||||||
|
<PreActions>
|
||||||
|
<ExecutionAction
|
||||||
|
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||||
|
<ActionContent
|
||||||
|
title = "Run Prepare Flutter Framework Script"
|
||||||
|
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||||
|
<EnvironmentBuildable>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</EnvironmentBuildable>
|
||||||
|
</ActionContent>
|
||||||
|
</ExecutionAction>
|
||||||
|
</PreActions>
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
|
|
|
||||||
122
ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved
Normal file
122
ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,13 @@ import UIKit
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
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)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
ios/Runner/CookieExtractorPlugin.swift
Normal file
27
ios/Runner/CookieExtractorPlugin.swift
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -54,5 +54,9 @@
|
||||||
<true/>
|
<true/>
|
||||||
<key>UIStatusBarHidden</key>
|
<key>UIStatusBarHidden</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
132
lib/apis/twitch_gql_api.dart
Normal file
132
lib/apis/twitch_gql_api.dart
Normal file
|
|
@ -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 = <String, String>{
|
||||||
|
'User-Agent': playbackUserAgent,
|
||||||
|
'Referer': 'https://player.twitch.tv',
|
||||||
|
'Origin': 'https://player.twitch.tv',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Fetches a playback access token for the given channel [login].
|
||||||
|
///
|
||||||
|
/// When [authToken] is provided, the request is authenticated which may
|
||||||
|
/// grant ad-free playback for subscribers and Turbo users.
|
||||||
|
///
|
||||||
|
/// Uses the web client's persisted GQL query (smaller payload, blends in
|
||||||
|
/// with regular web traffic); falls back to the inline query template if
|
||||||
|
/// Twitch ever rotates the persisted-query hash.
|
||||||
|
Future<PlaybackAccessToken> getPlaybackAccessToken({
|
||||||
|
required String login,
|
||||||
|
String? authToken,
|
||||||
|
}) async {
|
||||||
|
final headers = <String, String>{
|
||||||
|
'Client-ID': twitchGqlClientId,
|
||||||
|
'User-Agent': playbackUserAgent,
|
||||||
|
if (authToken != null) 'Authorization': 'OAuth $authToken',
|
||||||
|
};
|
||||||
|
|
||||||
|
final variables = {
|
||||||
|
'isLive': true,
|
||||||
|
'login': login,
|
||||||
|
'isVod': false,
|
||||||
|
'vodID': '',
|
||||||
|
'playerType': 'site',
|
||||||
|
'platform': 'web',
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<JsonMap> query(Map<String, Object> body) =>
|
||||||
|
post<JsonMap>('/gql', data: body, headers: headers);
|
||||||
|
|
||||||
|
final response = await query({
|
||||||
|
'operationName': 'PlaybackAccessToken',
|
||||||
|
'extensions': {
|
||||||
|
'persistedQuery': {
|
||||||
|
'version': 1,
|
||||||
|
// Stable for years; used by the web player and streamlink.
|
||||||
|
'sha256Hash':
|
||||||
|
'ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'variables': variables,
|
||||||
|
});
|
||||||
|
|
||||||
|
// PersistedQueryNotFound (or similar) comes back as a 200 with an
|
||||||
|
// errors array — retry once with the inline template before failing.
|
||||||
|
if (response['errors'] != null && response['data'] == null) {
|
||||||
|
debugPrint(
|
||||||
|
'TwitchGqlApi: persisted query failed (${response['errors']}), '
|
||||||
|
'falling back to inline template',
|
||||||
|
);
|
||||||
|
return PlaybackAccessToken.fromGqlResponse(
|
||||||
|
await query({
|
||||||
|
'operationName': 'PlaybackAccessToken_Template',
|
||||||
|
'query':
|
||||||
|
'query PlaybackAccessToken_Template(\$login: String!, \$isLive: Boolean!, \$vodID: ID!, \$isVod: Boolean!, \$playerType: String!) { streamPlaybackAccessToken(channelName: \$login, params: {platform: "web", playerBackend: "mediaplayer", playerType: \$playerType}) @include(if: \$isLive) { value signature __typename } videoPlaybackAccessToken(id: \$vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: \$playerType}) @include(if: \$isVod) { value signature __typename } }',
|
||||||
|
'variables': variables,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return PlaybackAccessToken.fromGqlResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,8 +14,9 @@ class UnauthorizedInterceptor extends Interceptor {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||||
// Check if this is a 401 Unauthorized error
|
// Check if this is a 401 Unauthorized error on a Twitch Helix/OAuth request
|
||||||
if (err.response?.statusCode == 401) {
|
if (err.response?.statusCode == 401 &&
|
||||||
|
_isTwitchHelixOrOAuth(err.requestOptions.uri)) {
|
||||||
// For token validation requests, let the error propagate so validateToken can handle it
|
// For token validation requests, let the error propagate so validateToken can handle it
|
||||||
if (err.requestOptions.uri.path.endsWith('/validate')) {
|
if (err.requestOptions.uri.path.endsWith('/validate')) {
|
||||||
handler.next(err);
|
handler.next(err);
|
||||||
|
|
@ -89,4 +90,10 @@ class UnauthorizedInterceptor extends Interceptor {
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool _isDialogShowing = false;
|
static bool _isDialogShowing = false;
|
||||||
|
|
||||||
|
bool _isTwitchHelixOrOAuth(Uri uri) {
|
||||||
|
final url = uri.toString();
|
||||||
|
return url.startsWith('https://api.twitch.tv/helix') ||
|
||||||
|
url.startsWith('https://id.twitch.tv/oauth2');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,18 @@ import 'package:flutter/material.dart';
|
||||||
/// Twitch API client ID.
|
/// Twitch API client ID.
|
||||||
const clientId = String.fromEnvironment('CLIENT_ID');
|
const clientId = String.fromEnvironment('CLIENT_ID');
|
||||||
|
|
||||||
|
/// Twitch GQL client ID (web player, used for playback access tokens).
|
||||||
|
const twitchGqlClientId = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
|
||||||
|
|
||||||
|
/// SharedPreferences key for the per-channel last-selected stream quality.
|
||||||
|
/// A saved variant that doesn't exist on the target channel (e.g. 1080p on a
|
||||||
|
/// source-only channel) falls back to Auto via the quality-list lookup.
|
||||||
|
String lastStreamQualityKey(String userLogin) =>
|
||||||
|
'last_stream_quality_$userLogin';
|
||||||
|
|
||||||
|
/// Legacy global key. Cleared on startup during SharedPreferences init.
|
||||||
|
const kLegacyLastStreamQualityKey = 'last_stream_quality';
|
||||||
|
|
||||||
/// Twitch API client secret.
|
/// Twitch API client secret.
|
||||||
const secret = String.fromEnvironment('SECRET');
|
const secret = String.fromEnvironment('SECRET');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,10 @@ import 'package:frosty/apis/ffz_api.dart';
|
||||||
import 'package:frosty/apis/seventv_api.dart';
|
import 'package:frosty/apis/seventv_api.dart';
|
||||||
import 'package:frosty/apis/twitch_api.dart';
|
import 'package:frosty/apis/twitch_api.dart';
|
||||||
import 'package:frosty/apis/twitch_auth_interceptor.dart';
|
import 'package:frosty/apis/twitch_auth_interceptor.dart';
|
||||||
|
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||||
import 'package:frosty/apis/unauthorized_interceptor.dart';
|
import 'package:frosty/apis/unauthorized_interceptor.dart';
|
||||||
import 'package:frosty/cache_manager.dart';
|
import 'package:frosty/cache_manager.dart';
|
||||||
|
import 'package:frosty/constants.dart';
|
||||||
import 'package:frosty/firebase_options.dart';
|
import 'package:frosty/firebase_options.dart';
|
||||||
import 'package:frosty/screens/channel/channel.dart';
|
import 'package:frosty/screens/channel/channel.dart';
|
||||||
import 'package:frosty/screens/home/home.dart';
|
import 'package:frosty/screens/home/home.dart';
|
||||||
|
|
@ -52,6 +54,12 @@ void main() async {
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
// One-time migration: the quality preference used to be stored under a
|
||||||
|
// single global key, which meant a saved 1080p60 from one channel could
|
||||||
|
// pin another channel to a variant it doesn't serve. Remove the legacy
|
||||||
|
// key; new writes go under per-channel keys.
|
||||||
|
await prefs.remove(kLegacyLastStreamQualityKey);
|
||||||
|
|
||||||
final firstRun = prefs.getBool('first_run') ?? true;
|
final firstRun = prefs.getBool('first_run') ?? true;
|
||||||
|
|
||||||
// Workaround for clearing stored tokens on uninstall.
|
// Workaround for clearing stored tokens on uninstall.
|
||||||
|
|
@ -104,6 +112,7 @@ void main() async {
|
||||||
final bttvApiService = BTTVApi(dioClient);
|
final bttvApiService = BTTVApi(dioClient);
|
||||||
final ffzApiService = FFZApi(dioClient);
|
final ffzApiService = FFZApi(dioClient);
|
||||||
final sevenTVApiService = SevenTVApi(dioClient);
|
final sevenTVApiService = SevenTVApi(dioClient);
|
||||||
|
final twitchGqlApiService = TwitchGqlApi(dioClient);
|
||||||
|
|
||||||
// Create global assets store (shared cache for global emotes/badges)
|
// Create global assets store (shared cache for global emotes/badges)
|
||||||
final globalAssetsStore = GlobalAssetsStore(
|
final globalAssetsStore = GlobalAssetsStore(
|
||||||
|
|
@ -137,6 +146,7 @@ void main() async {
|
||||||
Provider<BTTVApi>.value(value: bttvApiService),
|
Provider<BTTVApi>.value(value: bttvApiService),
|
||||||
Provider<FFZApi>.value(value: ffzApiService),
|
Provider<FFZApi>.value(value: ffzApiService),
|
||||||
Provider<SevenTVApi>.value(value: sevenTVApiService),
|
Provider<SevenTVApi>.value(value: sevenTVApiService),
|
||||||
|
Provider<TwitchGqlApi>.value(value: twitchGqlApiService),
|
||||||
Provider<GlobalAssetsStore>.value(value: globalAssetsStore),
|
Provider<GlobalAssetsStore>.value(value: globalAssetsStore),
|
||||||
],
|
],
|
||||||
child: MyApp(firstRun: firstRun),
|
child: MyApp(firstRun: firstRun),
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,9 @@ class IRCMessage {
|
||||||
size: badgeSize,
|
size: badgeSize,
|
||||||
color:
|
color:
|
||||||
Theme.of(context).iconTheme.color?.withValues(alpha: 0.5) ??
|
Theme.of(context).iconTheme.color?.withValues(alpha: 0.5) ??
|
||||||
Colors.grey.withValues(alpha: 0.5),
|
Theme.of(context).colorScheme.onSurfaceVariant.withValues(
|
||||||
|
alpha: 0.5,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -661,6 +663,8 @@ class IRCMessage {
|
||||||
bool showMessage = true,
|
bool showMessage = true,
|
||||||
Map<String, UserTwitch>? channelIdToUserTwitch,
|
Map<String, UserTwitch>? channelIdToUserTwitch,
|
||||||
TimestampType timestamp = TimestampType.disabled,
|
TimestampType timestamp = TimestampType.disabled,
|
||||||
|
bool forceTimestamp = false,
|
||||||
|
bool showHistoricalTimestamps = false,
|
||||||
String? currentChannelId,
|
String? currentChannelId,
|
||||||
}) {
|
}) {
|
||||||
final emoteToObject = assetsStore.emoteToObject;
|
final emoteToObject = assetsStore.emoteToObject;
|
||||||
|
|
@ -671,7 +675,19 @@ class IRCMessage {
|
||||||
// The span list that will be used to render the chat message
|
// The span list that will be used to render the chat message
|
||||||
final span = <InlineSpan>[];
|
final span = <InlineSpan>[];
|
||||||
|
|
||||||
_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(
|
_addHistoricalAndChannelBadges(
|
||||||
context,
|
context,
|
||||||
span,
|
span,
|
||||||
|
|
@ -680,7 +696,6 @@ class IRCMessage {
|
||||||
currentChannelId,
|
currentChannelId,
|
||||||
);
|
);
|
||||||
|
|
||||||
final isHistorical = tags['historical'] == '1';
|
|
||||||
_addUserBadges(
|
_addUserBadges(
|
||||||
context,
|
context,
|
||||||
span,
|
span,
|
||||||
|
|
|
||||||
65
lib/models/playback_access_token.dart
Normal file
65
lib/models/playback_access_token.dart
Normal file
|
|
@ -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<String> restrictedQualities;
|
||||||
|
|
||||||
|
const PlaybackAccessToken({
|
||||||
|
required this.value,
|
||||||
|
required this.signature,
|
||||||
|
this.hideAds = false,
|
||||||
|
this.restrictedQualities = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Names in restricted_bitrates that aren't real video qualities
|
||||||
|
/// (matches streamlink's filter).
|
||||||
|
static final _nonQualityNameRe = RegExp(r'^((.+_)?archives|live|chunked)');
|
||||||
|
|
||||||
|
factory PlaybackAccessToken.fromGqlResponse(Map<String, dynamic> json) {
|
||||||
|
final data =
|
||||||
|
(json['data'] as Map<String, dynamic>?)?['streamPlaybackAccessToken']
|
||||||
|
as Map<String, dynamic>?;
|
||||||
|
if (data == null) {
|
||||||
|
throw const FormatException(
|
||||||
|
'streamPlaybackAccessToken missing from GQL response',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final value = data['value'] as String;
|
||||||
|
var hideAds = false;
|
||||||
|
var restricted = const <String>[];
|
||||||
|
try {
|
||||||
|
final payload = jsonDecode(value) as Map<String, dynamic>;
|
||||||
|
hideAds = payload['hide_ads'] as bool? ?? false;
|
||||||
|
final bitrates =
|
||||||
|
(payload['chansub'] as Map<String, dynamic>?)?['restricted_bitrates']
|
||||||
|
as List<dynamic>?;
|
||||||
|
if (bitrates != null) {
|
||||||
|
restricted = bitrates
|
||||||
|
.whereType<String>()
|
||||||
|
.where((name) => !_nonQualityNameRe.hasMatch(name))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Token payload format is Twitch-internal — never fail playback over it.
|
||||||
|
debugPrint('PlaybackAccessToken: failed to parse token payload: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
return PlaybackAccessToken(
|
||||||
|
value: value,
|
||||||
|
signature: data['signature'] as String,
|
||||||
|
hideAds: hideAds,
|
||||||
|
restrictedQualities: restricted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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_store.dart';
|
||||||
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
|
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
|
||||||
import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart';
|
import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/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/stream_info_bar.dart';
|
||||||
import 'package:frosty/screens/channel/video/video.dart';
|
import 'package:frosty/screens/channel/video/video.dart';
|
||||||
import 'package:frosty/screens/channel/video/video_overlay.dart';
|
import 'package:frosty/screens/channel/video/video_overlay.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/video_player_interface.dart';
|
||||||
import 'package:frosty/screens/channel/video/video_store.dart';
|
import 'package:frosty/screens/channel/video/video_store.dart';
|
||||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||||
import 'package:frosty/theme.dart';
|
import 'package:frosty/theme.dart';
|
||||||
|
|
@ -43,11 +47,10 @@ class _VideoChatState extends State<VideoChat>
|
||||||
final _videoKey = GlobalKey();
|
final _videoKey = GlobalKey();
|
||||||
final _chatKey = GlobalKey();
|
final _chatKey = GlobalKey();
|
||||||
|
|
||||||
// PiP drag state - essential only
|
// PiP drag state — ValueNotifier avoids full widget rebuilds during drag.
|
||||||
double _pipDragDistance = 0;
|
final _pipDragDistance = ValueNotifier<double>(0);
|
||||||
bool _isPipDragging = false;
|
bool _isPipDragging = false;
|
||||||
bool _isInPipTriggerZone =
|
bool _isInPipTriggerZone = false;
|
||||||
false; // Track when in trigger zone for haptic feedback
|
|
||||||
|
|
||||||
// Divider drag state for synchronizing animation
|
// Divider drag state for synchronizing animation
|
||||||
bool _isDividerDragging = false;
|
bool _isDividerDragging = false;
|
||||||
|
|
@ -75,13 +78,24 @@ class _VideoChatState extends State<VideoChat>
|
||||||
primaryDisplayName: widget.userName,
|
primaryDisplayName: widget.userName,
|
||||||
);
|
);
|
||||||
|
|
||||||
late final VideoStore _videoStore = VideoStore(
|
late final VideoPlayerInterface _videoStore =
|
||||||
userLogin: widget.userLogin,
|
context.settingsStore.useNativePlayer
|
||||||
userId: widget.userId,
|
? NativeVideoStore(
|
||||||
twitchApi: context.twitchApi,
|
userLogin: widget.userLogin,
|
||||||
authStore: context.authStore,
|
userId: widget.userId,
|
||||||
settingsStore: context.settingsStore,
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -98,12 +112,10 @@ class _VideoChatState extends State<VideoChat>
|
||||||
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
|
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Attach listener to the controller (not the derived animation) so it
|
// Drive the ValueNotifier from the spring-back animation so the
|
||||||
// survives _springBackAnimation being reassigned in _animateSpringBack.
|
// AnimatedBuilder rebuilds without a full setState.
|
||||||
_animationController.addListener(() {
|
_animationController.addListener(() {
|
||||||
setState(() {
|
_pipDragDistance.value = _springBackAnimation.value;
|
||||||
_pipDragDistance = _springBackAnimation.value;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register as observer for app lifecycle events
|
// Register as observer for app lifecycle events
|
||||||
|
|
@ -120,58 +132,58 @@ class _VideoChatState extends State<VideoChat>
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handlePipDragStart(DragStartDetails details) {
|
void _handlePipDragStart(DragStartDetails details) {
|
||||||
// Disable drag gesture when already in PiP mode or video is not playing
|
if (_videoStore.isInPipMode || _videoStore.paused || _videoStore.loading) {
|
||||||
if (_videoStore.isInPipMode || _videoStore.paused) return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_animationController.stop(); // Stop any ongoing animation
|
_animationController.stop();
|
||||||
|
_pipDragDistance.value = 0;
|
||||||
|
_isInPipTriggerZone = false;
|
||||||
setState(() {
|
setState(() {
|
||||||
_isPipDragging = true;
|
_isPipDragging = true;
|
||||||
_pipDragDistance = 0;
|
|
||||||
_isInPipTriggerZone = false;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handlePipDragUpdate(DragUpdateDetails details) {
|
void _handlePipDragUpdate(DragUpdateDetails details) {
|
||||||
if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) {
|
if (!_isPipDragging ||
|
||||||
|
_videoStore.isInPipMode ||
|
||||||
|
_videoStore.paused ||
|
||||||
|
_videoStore.loading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
// Update the ValueNotifier directly — only the ListenableBuilder rebuilds,
|
||||||
_pipDragDistance += details.delta.dy;
|
// not the entire channel scaffold.
|
||||||
_pipDragDistance = _pipDragDistance.clamp(0, _pipMaxDragDistance);
|
_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;
|
||||||
final wasInTriggerZone = _isInPipTriggerZone;
|
_isInPipTriggerZone = _pipDragDistance.value >= _pipTriggerDistance;
|
||||||
_isInPipTriggerZone = _pipDragDistance >= _pipTriggerDistance;
|
|
||||||
|
|
||||||
// Provide haptic feedback when entering the trigger zone
|
if (!wasInTriggerZone && _isInPipTriggerZone) {
|
||||||
if (!wasInTriggerZone && _isInPipTriggerZone) {
|
HapticFeedback.mediumImpact();
|
||||||
HapticFeedback.mediumImpact(); // Entering trigger zone
|
} else if (wasInTriggerZone && !_isInPipTriggerZone) {
|
||||||
}
|
HapticFeedback.lightImpact();
|
||||||
// Provide subtle haptic feedback when exiting the trigger zone
|
}
|
||||||
else if (wasInTriggerZone && !_isInPipTriggerZone) {
|
|
||||||
HapticFeedback.lightImpact(); // Exiting trigger zone
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handlePipDragEnd(DragEndDetails details) {
|
void _handlePipDragEnd(DragEndDetails details) {
|
||||||
if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) {
|
if (!_isPipDragging ||
|
||||||
|
_videoStore.isInPipMode ||
|
||||||
|
_videoStore.paused ||
|
||||||
|
_videoStore.loading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final velocity = details.velocity.pixelsPerSecond.dy;
|
final velocity = details.velocity.pixelsPerSecond.dy;
|
||||||
final shouldTriggerPip =
|
final shouldTriggerPip =
|
||||||
_pipDragDistance >= _pipTriggerDistance ||
|
_pipDragDistance.value >= _pipTriggerDistance || velocity > 600;
|
||||||
velocity > 600; // Simple velocity threshold
|
|
||||||
|
|
||||||
if (shouldTriggerPip) {
|
if (shouldTriggerPip) {
|
||||||
// Simple haptic feedback on success
|
|
||||||
HapticFeedback.mediumImpact();
|
HapticFeedback.mediumImpact();
|
||||||
_videoStore.requestPictureInPicture();
|
_videoStore.requestPictureInPicture();
|
||||||
_resetDragState();
|
_resetDragState();
|
||||||
} else {
|
} else {
|
||||||
// Animate back to original position
|
|
||||||
_animateSpringBack();
|
_animateSpringBack();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -182,16 +194,16 @@ class _VideoChatState extends State<VideoChat>
|
||||||
}
|
}
|
||||||
|
|
||||||
void _resetDragState() {
|
void _resetDragState() {
|
||||||
|
_pipDragDistance.value = 0;
|
||||||
|
_isInPipTriggerZone = false;
|
||||||
setState(() {
|
setState(() {
|
||||||
_isPipDragging = false;
|
_isPipDragging = false;
|
||||||
_pipDragDistance = 0;
|
|
||||||
_isInPipTriggerZone = false;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _animateSpringBack() {
|
void _animateSpringBack() {
|
||||||
_springBackAnimation = Tween<double>(begin: _pipDragDistance, end: 0)
|
_springBackAnimation =
|
||||||
.animate(
|
Tween<double>(begin: _pipDragDistance.value, end: 0).animate(
|
||||||
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
|
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -229,13 +241,13 @@ class _VideoChatState extends State<VideoChat>
|
||||||
? AspectRatio(aspectRatio: aspectRatio, child: child)
|
? AspectRatio(aspectRatio: aspectRatio, child: child)
|
||||||
: child;
|
: child;
|
||||||
|
|
||||||
return AnimatedBuilder(
|
// Listen to the ValueNotifier (drag) and AnimationController (spring-back)
|
||||||
animation: _animationController,
|
// so only this subtree rebuilds during the gesture — not the full scaffold.
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: _pipDragDistance,
|
||||||
child: wrappedChild,
|
child: wrappedChild,
|
||||||
builder: (context, animatedChild) {
|
builder: (context, animatedChild) {
|
||||||
final currentDragDistance = _isPipDragging
|
final currentDragDistance = _pipDragDistance.value;
|
||||||
? _pipDragDistance
|
|
||||||
: _springBackAnimation.value;
|
|
||||||
|
|
||||||
final scaleFactor =
|
final scaleFactor =
|
||||||
1.0 - (currentDragDistance / _pipMaxDragDistance * 0.1);
|
1.0 - (currentDragDistance / _pipMaxDragDistance * 0.1);
|
||||||
|
|
@ -256,11 +268,13 @@ class _VideoChatState extends State<VideoChat>
|
||||||
if (!_videoStore.isInPipMode)
|
if (!_videoStore.isInPipMode)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
ignoring: !(_isPipDragging && _pipDragDistance > 0),
|
ignoring:
|
||||||
|
!(_isPipDragging && currentDragDistance > 0),
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
opacity: (_isPipDragging && _pipDragDistance > 0)
|
opacity:
|
||||||
? 1.0
|
(_isPipDragging && currentDragDistance > 0)
|
||||||
: 0.0,
|
? 1.0
|
||||||
|
: 0.0,
|
||||||
duration: const Duration(milliseconds: 150),
|
duration: const Duration(milliseconds: 150),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
child: Container(
|
child: Container(
|
||||||
|
|
@ -297,7 +311,12 @@ class _VideoChatState extends State<VideoChat>
|
||||||
|
|
||||||
final player = GestureDetector(
|
final player = GestureDetector(
|
||||||
onLongPress: _videoStore.handleToggleOverlay,
|
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(
|
final overlay = GestureDetector(
|
||||||
|
|
@ -324,7 +343,9 @@ class _VideoChatState extends State<VideoChat>
|
||||||
settingsStore: settingsStore,
|
settingsStore: settingsStore,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (_videoStore.paused || _videoStore.streamInfo == null) {
|
if (_videoStore.paused ||
|
||||||
|
_videoStore.streamInfo == null ||
|
||||||
|
_videoStore.loading) {
|
||||||
return videoOverlay;
|
return videoOverlay;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -344,15 +365,49 @@ class _VideoChatState extends State<VideoChat>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final video = Observer(
|
final video = Stack(
|
||||||
builder: (context) {
|
children: [
|
||||||
return Stack(
|
player,
|
||||||
children: [
|
Positioned.fill(
|
||||||
player,
|
child: Observer(
|
||||||
if (_videoStore.settingsStore.showOverlay) overlay,
|
builder: (_) => AnimatedOpacity(
|
||||||
],
|
opacity: _videoStore.loading ? 1.0 : 0.0,
|
||||||
);
|
// Appear instantly to cover native player placeholders during
|
||||||
},
|
// recovery; fade out smoothly when playback resumes.
|
||||||
|
duration: _videoStore.loading
|
||||||
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
// TickerMode stops the spinner's repeating animation while
|
||||||
|
// hidden — opacity 0 skips painting but would otherwise keep
|
||||||
|
// scheduling frames for the whole video session.
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: TickerMode(
|
||||||
|
enabled: _videoStore.loading,
|
||||||
|
child: const ColoredBox(
|
||||||
|
color: Colors.black,
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Colors.white38,
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Observer(
|
||||||
|
builder: (_) => _videoStore.settingsStore.showOverlay
|
||||||
|
? overlay
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
final chat = Observer(
|
final chat = Observer(
|
||||||
|
|
@ -683,7 +738,7 @@ class _VideoChatState extends State<VideoChat>
|
||||||
if (settingsStore.showVideo) ...[
|
if (settingsStore.showVideo) ...[
|
||||||
AspectRatio(
|
AspectRatio(
|
||||||
aspectRatio: 16 / 9,
|
aspectRatio: 16 / 9,
|
||||||
child: Container(), // Placeholder for video space
|
child: SizedBox(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
Expanded(child: chat),
|
Expanded(child: chat),
|
||||||
|
|
@ -710,9 +765,18 @@ class _VideoChatState extends State<VideoChat>
|
||||||
|
|
||||||
// If on Android, use PiPSwitcher to enable PiP functionality.
|
// If on Android, use PiPSwitcher to enable PiP functionality.
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
|
void notifyPip(bool isInPip) {
|
||||||
|
final store = _videoStore;
|
||||||
|
if (store is NativeVideoPlayerInterface) {
|
||||||
|
store.handleAndroidPipChanged(isInPip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return PipWidget(
|
return PipWidget(
|
||||||
pipLayout: PipActionsLayout.mediaOnlyPause,
|
pipLayout: PipActionsLayout.mediaOnlyPause,
|
||||||
onPipAction: (_) => _videoStore.handlePausePlay(),
|
onPipAction: (_) => _videoStore.handlePausePlay(),
|
||||||
|
onPipEntered: () => notifyPip(true),
|
||||||
|
onPipExited: () => notifyPip(false),
|
||||||
pipChild: player,
|
pipChild: player,
|
||||||
child: videoChat,
|
child: videoChat,
|
||||||
);
|
);
|
||||||
|
|
@ -731,6 +795,7 @@ class _VideoChatState extends State<VideoChat>
|
||||||
_videoStore.dispose();
|
_videoStore.dispose();
|
||||||
|
|
||||||
_animationController.dispose();
|
_animationController.dispose();
|
||||||
|
_pipDragDistance.dispose();
|
||||||
|
|
||||||
SystemChrome.setEnabledSystemUIMode(
|
SystemChrome.setEnabledSystemUIMode(
|
||||||
SystemUiMode.manual,
|
SystemUiMode.manual,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||||
import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart';
|
import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart';
|
||||||
import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart';
|
import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart';
|
||||||
|
|
@ -11,6 +12,10 @@ import 'package:frosty/utils/context_extensions.dart';
|
||||||
import 'package:frosty/widgets/frosty_page_view.dart';
|
import 'package:frosty/widgets/frosty_page_view.dart';
|
||||||
import 'package:frosty/widgets/frosty_scrollbar.dart';
|
import 'package:frosty/widgets/frosty_scrollbar.dart';
|
||||||
|
|
||||||
|
/// ~2x default to keep richer message widgets (emotes, badges, replies) built
|
||||||
|
/// ahead of fast scroll-back without holding the entire history.
|
||||||
|
const _chatCacheExtent = ScrollCacheExtent.pixels(500.0);
|
||||||
|
|
||||||
class Chat extends StatelessWidget {
|
class Chat extends StatelessWidget {
|
||||||
final ChatStore chatStore;
|
final ChatStore chatStore;
|
||||||
final EdgeInsetsGeometry? listPadding;
|
final EdgeInsetsGeometry? listPadding;
|
||||||
|
|
@ -145,6 +150,7 @@ class Chat extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
addAutomaticKeepAlives: false,
|
addAutomaticKeepAlives: false,
|
||||||
|
scrollCacheExtent: _chatCacheExtent,
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
itemCount: chatStore.renderMessages.length,
|
itemCount: chatStore.renderMessages.length,
|
||||||
itemBuilder: (context, index) => ChatMessage(
|
itemBuilder: (context, index) => ChatMessage(
|
||||||
|
|
@ -172,6 +178,7 @@ class Chat extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
addAutomaticKeepAlives: false,
|
addAutomaticKeepAlives: false,
|
||||||
|
scrollCacheExtent: _chatCacheExtent,
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
itemCount: mergedMessages.length,
|
itemCount: mergedMessages.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,7 @@ class ChatModes extends StatelessWidget {
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
context,
|
context,
|
||||||
).colorScheme.onSurfaceVariant,
|
).colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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/screens/settings/stores/settings_store.dart';
|
||||||
import 'package:frosty/utils.dart';
|
import 'package:frosty/utils.dart';
|
||||||
import 'package:mobx/mobx.dart';
|
import 'package:mobx/mobx.dart';
|
||||||
|
import 'package:web_socket_channel/io.dart';
|
||||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
|
||||||
part 'chat_store.g.dart';
|
part 'chat_store.g.dart';
|
||||||
|
|
@ -66,32 +67,41 @@ abstract class ChatStoreBase with Store {
|
||||||
'msg_bad_characters',
|
'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;
|
static const _baseBottomBarHeight = 68.0;
|
||||||
|
|
||||||
/// Height of the autocomplete bar (SizedBox + Divider).
|
/// Server-initiated graceful reconnect notice. Twitch sends this untagged
|
||||||
static const _autocompleteHeight = 51.0;
|
/// 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).
|
/// Muted words pre-normalized (trimmed, lowercased, empties dropped) by a
|
||||||
static const _replyBarHeight = 41.0;
|
/// reaction on the setting — avoids re-normalizing on every message.
|
||||||
|
var _normalizedMutedWords = <String>[];
|
||||||
|
|
||||||
/// Checks if IRC data contains a command that should bypass the chat delay.
|
/// Whether a single IRC [line] should bypass the chat delay and be handled
|
||||||
/// Returns true if any message in the data contains a bypass command.
|
/// immediately. Displayable chat content (PRIVMSG/USERNOTICE and the
|
||||||
bool _shouldBypassDelay(String data) {
|
/// moderation CLEARCHAT/CLEARMSG) is delayed, so it stays in sync with the
|
||||||
for (final message in data.trimRight().split('\r\n')) {
|
/// delayed video. Everything else is handled at once: control/state messages
|
||||||
if (message.startsWith('@')) {
|
/// (USERSTATE/ROOMSTATE/GLOBALUSERSTATE), NOTICE (so a send rejection surfaces
|
||||||
final parts = message.split(' ');
|
/// immediately instead of one chat-delay later), the reconnect notice, and
|
||||||
if (parts.length >= 3 && _delayBypassCommands.contains(parts[2])) {
|
/// any untagged server line (PING keepalives, CAP, JOIN, the welcome banner)
|
||||||
return true;
|
/// — 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;
|
||||||
return false;
|
// 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;
|
final TwitchApi twitchApi;
|
||||||
|
|
||||||
|
|
@ -131,6 +141,12 @@ abstract class ChatStoreBase with Store {
|
||||||
|
|
||||||
static const _maxRetries = 5;
|
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.
|
// The retry counter for exponential backoff.
|
||||||
var _retries = 0;
|
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.
|
/// Requested message to be sent by the user. Will only be sent on receipt of a USERNOTICE command.
|
||||||
IRCMessage? toSend;
|
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.
|
/// The list of reaction disposer functions that will be used later when disposing.
|
||||||
final reactions = <ReactionDisposer>[];
|
final reactions = <ReactionDisposer>[];
|
||||||
|
|
||||||
|
|
@ -300,25 +322,26 @@ abstract class ChatStoreBase with Store {
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Current bottom bar height based on visible overlays.
|
/// Measured height of the rendered bottom bar (input plus any reply or
|
||||||
/// Used to dynamically adjust chat message list padding.
|
/// 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<double?>(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
|
@computed
|
||||||
double get bottomBarHeight {
|
double get bottomBarHeight =>
|
||||||
var height = _baseBottomBarHeight;
|
_measuredBottomBarHeight.value ?? _baseBottomBarHeight;
|
||||||
|
|
||||||
// Add reply bar height when replying to a message.
|
/// Reports the bar's real rendered height. Sub-pixel changes are ignored to
|
||||||
if (replyingToMessage != null) {
|
/// avoid needless message-list relayouts.
|
||||||
height += _replyBarHeight;
|
void setBottomBarHeight(double height) {
|
||||||
}
|
final current = _measuredBottomBarHeight.value;
|
||||||
|
if (current != null && (current - height).abs() < 0.5) return;
|
||||||
// Add autocomplete bar height when showing matches.
|
runInAction(() => _measuredBottomBarHeight.value = height);
|
||||||
if (settings.autocomplete &&
|
|
||||||
((_showEmoteAutocomplete && matchingEmotes.isNotEmpty) ||
|
|
||||||
(_showMentionAutocomplete && matchingChatters.isNotEmpty))) {
|
|
||||||
height += _autocompleteHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
return height;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ChatStoreBase({
|
ChatStoreBase({
|
||||||
|
|
@ -337,6 +360,21 @@ abstract class ChatStoreBase with Store {
|
||||||
reaction((_) => auth.isLoggedIn, (_) => _channel?.sink.close(1000)),
|
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<String> words) {
|
||||||
|
_normalizedMutedWords = words
|
||||||
|
.map((word) => word.trim().toLowerCase())
|
||||||
|
.where((word) => word.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
},
|
||||||
|
fireImmediately: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
reactions.add(
|
reactions.add(
|
||||||
reaction(
|
reaction(
|
||||||
(_) => [
|
(_) => [
|
||||||
|
|
@ -449,7 +487,7 @@ abstract class ChatStoreBase with Store {
|
||||||
) {
|
) {
|
||||||
queue.add(
|
queue.add(
|
||||||
_DelayedCallback(
|
_DelayedCallback(
|
||||||
releaseAt: DateTime.now().add(Duration(seconds: _chatDelaySeconds)),
|
releaseAt: DateTime.now().add(_chatDelay),
|
||||||
callback: callback,
|
callback: callback,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -518,6 +556,15 @@ abstract class ChatStoreBase with Store {
|
||||||
// To account for this, split by CRLF, then loop and process each message.
|
// To account for this, split by CRLF, then loop and process each message.
|
||||||
for (final message in data.trimRight().split('\r\n')) {
|
for (final message in data.trimRight().split('\r\n')) {
|
||||||
// debugPrint('$message\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('@')) {
|
if (message.startsWith('@')) {
|
||||||
// Parse defensively: a single malformed message must never abort the
|
// Parse defensively: a single malformed message must never abort the
|
||||||
// loop, or one bad frame would drop every other message batched with
|
// loop, or one bad frame would drop every other message batched with
|
||||||
|
|
@ -549,19 +596,18 @@ abstract class ChatStoreBase with Store {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter messages containing any muted words.
|
// Filter messages containing any muted words. Must use `continue`
|
||||||
if (parsedIRCMessage.message != null) {
|
// (not `return`) — a single IRC frame can batch many messages, and
|
||||||
final List<String> mutedWords = settings.mutedWords;
|
// returning would drop the rest of the frame with the matched one.
|
||||||
|
if (parsedIRCMessage.message != null &&
|
||||||
// check if the message contains any of the muted words
|
_normalizedMutedWords.isNotEmpty) {
|
||||||
for (final word in mutedWords) {
|
final msg = parsedIRCMessage.message!.toLowerCase();
|
||||||
if (parsedIRCMessage.message!
|
final words = settings.matchWholeWord ? msg.split(' ') : null;
|
||||||
.toLowerCase()
|
final muted = _normalizedMutedWords.any(
|
||||||
.split(settings.matchWholeWord ? ' ' : '')
|
(word) =>
|
||||||
.contains(word.toLowerCase())) {
|
words != null ? words.contains(word) : msg.contains(word),
|
||||||
return;
|
);
|
||||||
}
|
if (muted) continue;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (parsedIRCMessage.command) {
|
switch (parsedIRCMessage.command) {
|
||||||
|
|
@ -577,6 +623,9 @@ abstract class ChatStoreBase with Store {
|
||||||
_sendingTimeoutTimer?.cancel();
|
_sendingTimeoutTimer?.cancel();
|
||||||
_isWaitingForAck = false;
|
_isWaitingForAck = false;
|
||||||
toSend = null;
|
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
|
// Show notification with Twitch's rejection message
|
||||||
if (parsedIRCMessage.message != null) {
|
if (parsedIRCMessage.message != null) {
|
||||||
|
|
@ -645,23 +694,13 @@ abstract class ChatStoreBase with Store {
|
||||||
case Command.userState:
|
case Command.userState:
|
||||||
_userState = _userState.fromIRCMessage(parsedIRCMessage);
|
_userState = _userState.fromIRCMessage(parsedIRCMessage);
|
||||||
|
|
||||||
// USERSTATE arrival confirms message was sent, even without id tag
|
// A USERSTATE following our PRIVMSG confirms the send. Finalize
|
||||||
if (toSend != null) {
|
// regardless of whether a local echo was pre-built — gating this on
|
||||||
final messageId = parsedIRCMessage.tags['id'];
|
// `toSend` left the player stuck "sending" (and firing a false
|
||||||
if (messageId != null) {
|
// "may not have been sent") whenever the message went out before
|
||||||
toSend!.tags['id'] = messageId;
|
// this session's first USERSTATE populated `_userState`.
|
||||||
} else {
|
if (_isWaitingForAck) {
|
||||||
// Fallback ID for edge cases where Twitch doesn't provide one
|
_finalizeSend(parsedIRCMessage.tags['id']);
|
||||||
toSend!.tags['id'] =
|
|
||||||
'local-${DateTime.now().millisecondsSinceEpoch}';
|
|
||||||
}
|
|
||||||
messageBuffer.add(toSend!);
|
|
||||||
toSend = null;
|
|
||||||
// Reset sending state and clear text/reply after successful confirmation
|
|
||||||
_sendingTimeoutTimer?.cancel();
|
|
||||||
_isWaitingForAck = false;
|
|
||||||
textController.clear();
|
|
||||||
replyingToMessage = null;
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case Command.globalUserState:
|
case Command.globalUserState:
|
||||||
|
|
@ -825,8 +864,9 @@ abstract class ChatStoreBase with Store {
|
||||||
|
|
||||||
_sevenTVChannel?.sink.close(1000);
|
_sevenTVChannel?.sink.close(1000);
|
||||||
_clearPendingDelayedCallbacks(clearChat: false);
|
_clearPendingDelayedCallbacks(clearChat: false);
|
||||||
_sevenTVChannel = WebSocketChannel.connect(
|
_sevenTVChannel = IOWebSocketChannel.connect(
|
||||||
Uri.parse('wss://events.7tv.io/v3'),
|
Uri.parse('wss://events.7tv.io/v3'),
|
||||||
|
pingInterval: _pingInterval,
|
||||||
);
|
);
|
||||||
|
|
||||||
void listener(dynamic data) {
|
void listener(dynamic data) {
|
||||||
|
|
@ -913,8 +953,9 @@ abstract class ChatStoreBase with Store {
|
||||||
_clearPendingDelayedCallbacks(clearSevenTV: false);
|
_clearPendingDelayedCallbacks(clearSevenTV: false);
|
||||||
|
|
||||||
_channel?.sink.close(1000);
|
_channel?.sink.close(1000);
|
||||||
_channel = WebSocketChannel.connect(
|
_channel = IOWebSocketChannel.connect(
|
||||||
Uri.parse('wss://irc-ws.chat.twitch.tv:443'),
|
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
|
// Only show chat delay countdown on initial connection or video toggle, not on reconnects
|
||||||
|
|
@ -927,13 +968,27 @@ abstract class ChatStoreBase with Store {
|
||||||
(data) {
|
(data) {
|
||||||
final dataStr = data.toString();
|
final dataStr = data.toString();
|
||||||
|
|
||||||
// Process immediately if delay is disabled or command should bypass delay
|
// No active delay: process the whole frame immediately.
|
||||||
if (!_hasActiveChatDelay || _shouldBypassDelay(dataStr)) {
|
if (!_hasActiveChatDelay) {
|
||||||
_handleIRCData(dataStr);
|
_handleIRCData(dataStr);
|
||||||
} else {
|
return;
|
||||||
_enqueueDelayedCallback(_pendingChatCallbacks, () {
|
}
|
||||||
_handleIRCData(dataStr);
|
|
||||||
});
|
// 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) {
|
onError: (error) {
|
||||||
|
|
@ -954,6 +1009,14 @@ abstract class ChatStoreBase with Store {
|
||||||
_cancelChatDelayCountdown();
|
_cancelChatDelayCountdown();
|
||||||
_chatDelaySyncCompleted = false;
|
_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) {
|
if (_shouldDisconnect) {
|
||||||
_sevenTVChannel?.sink.close(1000);
|
_sevenTVChannel?.sink.close(1000);
|
||||||
return;
|
return;
|
||||||
|
|
@ -1124,7 +1187,7 @@ abstract class ChatStoreBase with Store {
|
||||||
messageBuffer.clear();
|
messageBuffer.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
var remainingSeconds = _chatDelaySeconds;
|
var remainingSeconds = _chatDelay.inSeconds;
|
||||||
|
|
||||||
// Create and store reference to the countdown message
|
// Create and store reference to the countdown message
|
||||||
_countdownMessage = IRCMessage.createNotice(
|
_countdownMessage = IRCMessage.createNotice(
|
||||||
|
|
@ -1209,53 +1272,92 @@ abstract class ChatStoreBase with Store {
|
||||||
// Text field and reply state cleared on USERSTATE confirmation (not optimistically)
|
// Text field and reply state cleared on USERSTATE confirmation (not optimistically)
|
||||||
// to preserve message text if Twitch rejects it (e.g., slow mode)
|
// to preserve message text if Twitch rejects it (e.g., slow mode)
|
||||||
|
|
||||||
// Start timeout timer in case server doesn't respond (network failure)
|
// Fallback in case the confirming USERSTATE never arrives. Twitch can omit
|
||||||
// Must be outside userStateString conditional to always have a safety net
|
// 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?.cancel();
|
||||||
_sendingTimeoutTimer = Timer(const Duration(seconds: 10), () {
|
_sendingTimeoutTimer = Timer(const Duration(seconds: 10) + _chatDelay, () {
|
||||||
// Guard against execution after disposal
|
if (_shouldDisconnect || !_isWaitingForAck) return;
|
||||||
if (_shouldDisconnect) return;
|
runInAction(() => _finalizeSend(null));
|
||||||
|
|
||||||
if (_isWaitingForAck) {
|
|
||||||
_isWaitingForAck = false;
|
|
||||||
toSend = null;
|
|
||||||
HapticFeedback.heavyImpact();
|
|
||||||
updateNotification('Message may not have been sent. Please try again.');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Obtain the logged-in user's appearance in chat with USERSTATE and create the full message to render.
|
// Build the optimistic local echo from the current USERSTATE. If the
|
||||||
var userStateString = _userState.raw;
|
// message is sent before this session's first USERSTATE arrives,
|
||||||
if (userStateString != null) {
|
// _buildLocalEcho returns null and the echo is built later, when the
|
||||||
if (message.length > 3 && message.substring(0, 3) == '/me') {
|
// confirming USERSTATE lands (see the Command.userState handler). The raw
|
||||||
userStateString +=
|
// text is retained so that deferred build is possible.
|
||||||
' :\x01ACTION ${message.replaceRange(0, 3, '').trim()}\x01';
|
_pendingSentMessageText = message;
|
||||||
} else {
|
toSend = _buildLocalEcho(message);
|
||||||
userStateString +=
|
}
|
||||||
' :${replyingToMessage?.tags['display-name'] != null ? '@${replyingToMessage!.tags['display-name']} ' : ''}${message.trim()}';
|
|
||||||
}
|
|
||||||
|
|
||||||
final userChatMessage = IRCMessage.fromString(userStateString);
|
/// Renders the pending outgoing message's local echo (building it lazily if
|
||||||
userChatMessage.localEmotes?.addAll(assetsStore.userEmoteToObject);
|
/// it couldn't be built at send time) and clears all send-pending state.
|
||||||
if (auth.isLoggedIn && auth.user.details != null) {
|
/// Shared by the USERSTATE confirmation and the optimistic timeout fallback.
|
||||||
userChatMessage.tags['user-id'] = auth.user.details!.id;
|
/// [serverMessageId] is the `id` tag from the confirming USERSTATE, or null
|
||||||
}
|
/// when finalizing optimistically without one.
|
||||||
|
void _finalizeSend(String? serverMessageId) {
|
||||||
if (replyingToMessage != null && replyingToMessage!.tags['id'] != null) {
|
toSend ??= _pendingSentMessageText != null
|
||||||
userChatMessage.tags['reply-parent-msg-id'] =
|
? _buildLocalEcho(_pendingSentMessageText!)
|
||||||
replyingToMessage!.tags['id']!;
|
: null;
|
||||||
userChatMessage.tags['reply-parent-display-name'] =
|
if (toSend != null) {
|
||||||
replyingToMessage!.tags['display-name'] ?? '';
|
// Prefer the server id; fall back to any id already set, then a local one.
|
||||||
userChatMessage.tags['reply-parent-msg-body'] =
|
toSend!.tags['id'] = serverMessageId ??
|
||||||
replyingToMessage!.message ?? '';
|
toSend!.tags['id'] ??
|
||||||
}
|
'local-${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
messageBuffer.add(toSend!);
|
||||||
// Ensure a timestamp so merged-mode sorting works correctly.
|
toSend = null;
|
||||||
userChatMessage.tags['tmi-sent-ts'] ??=
|
|
||||||
'${DateTime.now().millisecondsSinceEpoch}';
|
|
||||||
|
|
||||||
toSend = userChatMessage;
|
|
||||||
}
|
}
|
||||||
|
_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.
|
/// Adds the given [emote] to the chat textfield.
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,10 @@ class ChatTabInfo {
|
||||||
/// The ChatStore for this tab. Null until the tab is activated (lazy loading).
|
/// The ChatStore for this tab. Null until the tab is activated (lazy loading).
|
||||||
ChatStore? chatStore;
|
ChatStore? chatStore;
|
||||||
|
|
||||||
|
/// The id of the most recent message visible the last time the user viewed
|
||||||
|
/// this tab. Used to compute unread state — null means "no baseline yet".
|
||||||
|
String? lastSeenMessageId;
|
||||||
|
|
||||||
/// Whether this is the primary tab (first tab, cannot be removed).
|
/// Whether this is the primary tab (first tab, cannot be removed).
|
||||||
final bool isPrimary;
|
final bool isPrimary;
|
||||||
|
|
||||||
|
|
@ -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;
|
final newIndex = _tabs.length - 1;
|
||||||
activateTab(newIndex);
|
setActiveTab(newIndex);
|
||||||
activeTabIndex = newIndex;
|
|
||||||
|
|
||||||
// If in merged mode, fetch the new tab's channel profile
|
// If in merged mode, fetch the new tab's channel profile
|
||||||
if (mergedMode) {
|
if (mergedMode) {
|
||||||
|
|
@ -639,6 +642,30 @@ abstract class ChatTabsStoreBase with Store {
|
||||||
_syncSecondaryTabsToSettings();
|
_syncSecondaryTabsToSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the last message id in [messages] that has a non-null tag id,
|
||||||
|
/// or null if no such message exists. Skips system messages without ids.
|
||||||
|
String? _latestMessageId(List<IRCMessage> messages) {
|
||||||
|
for (var i = messages.length - 1; i >= 0; i--) {
|
||||||
|
final id = messages[i].tags['id'];
|
||||||
|
if (id != null) return id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if [index] has unread messages since the user last viewed it.
|
||||||
|
/// Always false for the active tab and in merged mode.
|
||||||
|
bool hasUnreadMessages(int index) {
|
||||||
|
if (mergedMode) return false;
|
||||||
|
if (index == activeTabIndex) return false;
|
||||||
|
if (index < 0 || index >= _tabs.length) return false;
|
||||||
|
final tab = _tabs[index];
|
||||||
|
final store = tab.chatStore;
|
||||||
|
if (store == null) return false;
|
||||||
|
final latest = _latestMessageId(store.messages);
|
||||||
|
if (latest == null) return false;
|
||||||
|
return latest != tab.lastSeenMessageId;
|
||||||
|
}
|
||||||
|
|
||||||
/// Sets the active tab to the given index.
|
/// Sets the active tab to the given index.
|
||||||
///
|
///
|
||||||
/// When [silent] is true, the current tab's draft text and reply state are
|
/// When [silent] is true, the current tab's draft text and reply state are
|
||||||
|
|
@ -647,6 +674,18 @@ abstract class ChatTabsStoreBase with Store {
|
||||||
@action
|
@action
|
||||||
void setActiveTab(int index, {bool silent = false}) {
|
void setActiveTab(int index, {bool silent = false}) {
|
||||||
if (index >= 0 && index < _tabs.length) {
|
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.
|
// Clear text input and emote menu when switching tabs.
|
||||||
// Skip in merged mode — tabs act as send-target selectors, not
|
// Skip in merged mode — tabs act as send-target selectors, not
|
||||||
// view switches, so draft/reply state should be preserved.
|
// view switches, so draft/reply state should be preserved.
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import 'package:frosty/utils/modal_bottom_sheet.dart';
|
||||||
import 'package:frosty/widgets/blurred_container.dart';
|
import 'package:frosty/widgets/blurred_container.dart';
|
||||||
import 'package:frosty/widgets/chat_input/emote_text_span_builder.dart';
|
import 'package:frosty/widgets/chat_input/emote_text_span_builder.dart';
|
||||||
import 'package:frosty/widgets/frosty_cached_network_image.dart';
|
import 'package:frosty/widgets/frosty_cached_network_image.dart';
|
||||||
|
import 'package:frosty/widgets/measure_size.dart';
|
||||||
|
|
||||||
class ChatBottomBar extends StatelessWidget {
|
class ChatBottomBar extends StatelessWidget {
|
||||||
final ChatStore chatStore;
|
final ChatStore chatStore;
|
||||||
|
|
@ -393,6 +394,13 @@ class ChatBottomBar extends StatelessWidget {
|
||||||
final needsBottomPadding =
|
final needsBottomPadding =
|
||||||
!chatStore.assetsStore.showEmoteMenu && !isHorizontalLandscape;
|
!chatStore.assetsStore.showEmoteMenu && !isHorizontalLandscape;
|
||||||
|
|
||||||
|
// Measured once here; the list pads itself by this height so the bar
|
||||||
|
// never overlaps messages. Shared by both container branches below.
|
||||||
|
final measuredBar = MeasureSize(
|
||||||
|
onChange: (size) => chatStore.setBottomBarHeight(size.height),
|
||||||
|
child: bottomBarContent,
|
||||||
|
);
|
||||||
|
|
||||||
return isFullscreenOverlay
|
return isFullscreenOverlay
|
||||||
? Padding(
|
? Padding(
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
|
|
@ -400,7 +408,7 @@ class ChatBottomBar extends StatelessWidget {
|
||||||
? MediaQuery.of(context).padding.bottom
|
? MediaQuery.of(context).padding.bottom
|
||||||
: 0,
|
: 0,
|
||||||
),
|
),
|
||||||
child: bottomBarContent,
|
child: measuredBar,
|
||||||
)
|
)
|
||||||
: BlurredContainer(
|
: BlurredContainer(
|
||||||
gradientDirection: GradientDirection.down,
|
gradientDirection: GradientDirection.down,
|
||||||
|
|
@ -409,7 +417,7 @@ class ChatBottomBar extends StatelessWidget {
|
||||||
? MediaQuery.of(context).padding.bottom
|
? MediaQuery.of(context).padding.bottom
|
||||||
: 0,
|
: 0,
|
||||||
),
|
),
|
||||||
child: bottomBarContent,
|
child: measuredBar,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ class ChatMessage extends StatelessWidget {
|
||||||
final IRCMessage ircMessage;
|
final IRCMessage ircMessage;
|
||||||
final ChatStore chatStore;
|
final ChatStore chatStore;
|
||||||
final bool isModal;
|
final bool isModal;
|
||||||
|
final bool forceTimestamp;
|
||||||
final bool showReplyHeader;
|
final bool showReplyHeader;
|
||||||
final bool isInReplyThread;
|
final bool isInReplyThread;
|
||||||
|
|
||||||
|
|
@ -39,6 +40,7 @@ class ChatMessage extends StatelessWidget {
|
||||||
required this.ircMessage,
|
required this.ircMessage,
|
||||||
required this.chatStore,
|
required this.chatStore,
|
||||||
this.isModal = false,
|
this.isModal = false,
|
||||||
|
this.forceTimestamp = false,
|
||||||
this.showReplyHeader = true,
|
this.showReplyHeader = true,
|
||||||
this.isInReplyThread = false,
|
this.isInReplyThread = false,
|
||||||
this.overrideChannelIdToUserTwitch,
|
this.overrideChannelIdToUserTwitch,
|
||||||
|
|
@ -65,6 +67,7 @@ class ChatMessage extends StatelessWidget {
|
||||||
username: ircMessage.user!,
|
username: ircMessage.user!,
|
||||||
userId: ircMessage.tags['user-id']!,
|
userId: ircMessage.tags['user-id']!,
|
||||||
displayName: ircMessage.tags['display-name']!,
|
displayName: ircMessage.tags['display-name']!,
|
||||||
|
onActivateSourceTab: onActivateSourceTab,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -84,6 +87,7 @@ class ChatMessage extends StatelessWidget {
|
||||||
username: user.login,
|
username: user.login,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
|
onActivateSourceTab: onActivateSourceTab,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -126,7 +130,11 @@ class ChatMessage extends StatelessWidget {
|
||||||
badgeScale: chatStore.settings.badgeScale,
|
badgeScale: chatStore.settings.badgeScale,
|
||||||
launchExternal: chatStore.settings.launchUrlExternal,
|
launchExternal: chatStore.settings.launchUrlExternal,
|
||||||
timestamp: chatStore.settings.timestampType,
|
timestamp: chatStore.settings.timestampType,
|
||||||
channelIdToUserTwitch: overrideChannelIdToUserTwitch ??
|
forceTimestamp: forceTimestamp,
|
||||||
|
showHistoricalTimestamps:
|
||||||
|
chatStore.settings.showHistoricalTimestamps,
|
||||||
|
channelIdToUserTwitch:
|
||||||
|
overrideChannelIdToUserTwitch ??
|
||||||
chatStore.assetsStore.channelIdToUserTwitch,
|
chatStore.assetsStore.channelIdToUserTwitch,
|
||||||
currentChannelId:
|
currentChannelId:
|
||||||
overrideCurrentChannelId ?? chatStore.channelId,
|
overrideCurrentChannelId ?? chatStore.channelId,
|
||||||
|
|
@ -234,7 +242,11 @@ class ChatMessage extends StatelessWidget {
|
||||||
badgeScale: chatStore.settings.badgeScale,
|
badgeScale: chatStore.settings.badgeScale,
|
||||||
launchExternal: chatStore.settings.launchUrlExternal,
|
launchExternal: chatStore.settings.launchUrlExternal,
|
||||||
timestamp: chatStore.settings.timestampType,
|
timestamp: chatStore.settings.timestampType,
|
||||||
channelIdToUserTwitch: overrideChannelIdToUserTwitch ??
|
forceTimestamp: forceTimestamp,
|
||||||
|
showHistoricalTimestamps:
|
||||||
|
chatStore.settings.showHistoricalTimestamps,
|
||||||
|
channelIdToUserTwitch:
|
||||||
|
overrideChannelIdToUserTwitch ??
|
||||||
chatStore.assetsStore.channelIdToUserTwitch,
|
chatStore.assetsStore.channelIdToUserTwitch,
|
||||||
currentChannelId:
|
currentChannelId:
|
||||||
overrideCurrentChannelId ?? chatStore.channelId,
|
overrideCurrentChannelId ?? chatStore.channelId,
|
||||||
|
|
@ -389,9 +401,12 @@ class ChatMessage extends StatelessWidget {
|
||||||
},
|
},
|
||||||
launchExternal: chatStore.settings.launchUrlExternal,
|
launchExternal: chatStore.settings.launchUrlExternal,
|
||||||
timestamp: chatStore.settings.timestampType,
|
timestamp: chatStore.settings.timestampType,
|
||||||
|
forceTimestamp: forceTimestamp,
|
||||||
|
showHistoricalTimestamps:
|
||||||
|
chatStore.settings.showHistoricalTimestamps,
|
||||||
channelIdToUserTwitch:
|
channelIdToUserTwitch:
|
||||||
overrideChannelIdToUserTwitch ??
|
overrideChannelIdToUserTwitch ??
|
||||||
chatStore.assetsStore.channelIdToUserTwitch,
|
chatStore.assetsStore.channelIdToUserTwitch,
|
||||||
currentChannelId:
|
currentChannelId:
|
||||||
overrideCurrentChannelId ?? chatStore.channelId,
|
overrideCurrentChannelId ?? chatStore.channelId,
|
||||||
),
|
),
|
||||||
|
|
@ -482,10 +497,14 @@ class ChatMessage extends StatelessWidget {
|
||||||
messageHeaderIcon = Icon(
|
messageHeaderIcon = Icon(
|
||||||
Icons.campaign_rounded,
|
Icons.campaign_rounded,
|
||||||
size: messageHeaderIconSize,
|
size: messageHeaderIconSize,
|
||||||
|
color: messageHeaderTextColor,
|
||||||
);
|
);
|
||||||
messageHeader = const Text(
|
messageHeader = Text(
|
||||||
'Announcement',
|
'Announcement',
|
||||||
style: TextStyle(fontWeight: messageHeaderFontWeight),
|
style: TextStyle(
|
||||||
|
fontWeight: messageHeaderFontWeight,
|
||||||
|
color: messageHeaderTextColor,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -514,9 +533,12 @@ class ChatMessage extends StatelessWidget {
|
||||||
badgeScale: chatStore.settings.badgeScale,
|
badgeScale: chatStore.settings.badgeScale,
|
||||||
launchExternal: chatStore.settings.launchUrlExternal,
|
launchExternal: chatStore.settings.launchUrlExternal,
|
||||||
timestamp: chatStore.settings.timestampType,
|
timestamp: chatStore.settings.timestampType,
|
||||||
|
forceTimestamp: forceTimestamp,
|
||||||
|
showHistoricalTimestamps:
|
||||||
|
chatStore.settings.showHistoricalTimestamps,
|
||||||
channelIdToUserTwitch:
|
channelIdToUserTwitch:
|
||||||
overrideChannelIdToUserTwitch ??
|
overrideChannelIdToUserTwitch ??
|
||||||
chatStore.assetsStore.channelIdToUserTwitch,
|
chatStore.assetsStore.channelIdToUserTwitch,
|
||||||
currentChannelId:
|
currentChannelId:
|
||||||
overrideCurrentChannelId ?? chatStore.channelId,
|
overrideCurrentChannelId ?? chatStore.channelId,
|
||||||
),
|
),
|
||||||
|
|
@ -589,6 +611,19 @@ class ChatMessage extends StatelessWidget {
|
||||||
child: dividedMessage,
|
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(
|
final finalMessage = InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
FocusScope.of(context).unfocus();
|
FocusScope.of(context).unfocus();
|
||||||
|
|
@ -597,7 +632,7 @@ class ChatMessage extends StatelessWidget {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLongPress: () => onLongPressMessage(context, defaultTextStyle),
|
onLongPress: () => onLongPressMessage(context, defaultTextStyle),
|
||||||
child: coloredMessage,
|
child: fadedMessage,
|
||||||
);
|
);
|
||||||
|
|
||||||
return finalMessage;
|
return finalMessage;
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,6 @@ class ChatTabs extends StatelessWidget {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!added && context.mounted) {
|
if (!added && context.mounted) {
|
||||||
// If not added, it means the channel already exists (switched to it)
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Channel already open, switched to it'),
|
content: Text('Channel already open, switched to it'),
|
||||||
|
|
@ -56,16 +55,10 @@ class ChatTabs extends StatelessWidget {
|
||||||
final tabs = chatTabsStore.tabs;
|
final tabs = chatTabsStore.tabs;
|
||||||
final activeIndex = chatTabsStore.activeTabIndex;
|
final activeIndex = chatTabsStore.activeTabIndex;
|
||||||
final showTabBar = chatTabsStore.showTabBar;
|
final showTabBar = chatTabsStore.showTabBar;
|
||||||
final showMerge =
|
final showMerge = tabs.where((t) => t.isActivated).length >= 2;
|
||||||
tabs.where((t) => t.isActivated).length >= 2;
|
|
||||||
|
|
||||||
// Calculate extra top padding for tab bar when visible
|
|
||||||
final tabBarHeight = showTabBar ? 48.0 : 0.0;
|
final tabBarHeight = showTabBar ? 48.0 : 0.0;
|
||||||
|
|
||||||
// Get the top inset from listPadding (e.g., for AppBar in chat-only mode)
|
|
||||||
final topInset = listPadding?.resolve(TextDirection.ltr).top ?? 0;
|
final topInset = listPadding?.resolve(TextDirection.ltr).top ?? 0;
|
||||||
|
|
||||||
// Adjust list padding to account for tab bar
|
|
||||||
final adjustedPadding = listPadding != null
|
final adjustedPadding = listPadding != null
|
||||||
? listPadding!.add(EdgeInsets.only(top: tabBarHeight))
|
? listPadding!.add(EdgeInsets.only(top: tabBarHeight))
|
||||||
: EdgeInsets.only(top: tabBarHeight);
|
: EdgeInsets.only(top: tabBarHeight);
|
||||||
|
|
@ -75,27 +68,20 @@ class ChatTabs extends StatelessWidget {
|
||||||
onPopInvokedWithResult: (didPop, _) {
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
if (didPop) return;
|
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;
|
final activeStore = chatTabsStore.activeChatStore;
|
||||||
|
|
||||||
// Priority 1: Close emote menu
|
|
||||||
if (activeStore.assetsStore.showEmoteMenu) {
|
if (activeStore.assetsStore.showEmoteMenu) {
|
||||||
activeStore.assetsStore.showEmoteMenu = false;
|
activeStore.assetsStore.showEmoteMenu = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: Unfocus keyboard
|
|
||||||
if (activeStore.textFieldFocusNode.hasFocus) {
|
if (activeStore.textFieldFocusNode.hasFocus) {
|
||||||
activeStore.unfocusInput();
|
activeStore.unfocusInput();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 3: Navigate back
|
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// Chat content: merged view or IndexedStack
|
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: chatTabsStore.mergedMode
|
child: chatTabsStore.mergedMode
|
||||||
? Chat(
|
? Chat(
|
||||||
|
|
@ -107,7 +93,6 @@ class ChatTabs extends StatelessWidget {
|
||||||
: IndexedStack(
|
: IndexedStack(
|
||||||
index: activeIndex,
|
index: activeIndex,
|
||||||
children: tabs.map((tabInfo) {
|
children: tabs.map((tabInfo) {
|
||||||
// Show placeholder for non-activated tabs
|
|
||||||
if (tabInfo.chatStore == null) {
|
if (tabInfo.chatStore == null) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
@ -120,7 +105,6 @@ class ChatTabs extends StatelessWidget {
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Tab bar (only visible when more than 1 tab)
|
|
||||||
if (showTabBar)
|
if (showTabBar)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: topInset,
|
top: topInset,
|
||||||
|
|
@ -130,8 +114,8 @@ class ChatTabs extends StatelessWidget {
|
||||||
height: 48,
|
height: 48,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// When merge button is visible, clips at its center
|
// Right-clip past the merge button so chips slide
|
||||||
// so chips slide under its left half then disappear.
|
// under its left half then disappear.
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
|
|
@ -161,7 +145,7 @@ class ChatTabs extends StatelessWidget {
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
right: index < tabs.length - 1 ? 4 : 0,
|
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,
|
right: 12,
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 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) {
|
Widget _buildMoreActionsMenu(BuildContext context) {
|
||||||
final isMerged = chatTabsStore.mergedMode;
|
final settings = chatTabsStore.activeChatStore.settings;
|
||||||
return IconButton.filledTonal(
|
|
||||||
icon: const Icon(Icons.call_merge, size: 18),
|
return _AnchoredPopupMenu(
|
||||||
tooltip: isMerged ? 'Split chats' : 'Merge loaded chats',
|
itemsBuilder: (close) => [
|
||||||
visualDensity: VisualDensity.compact,
|
Observer(
|
||||||
isSelected: isMerged,
|
builder: (_) => CheckboxMenuButton(
|
||||||
style: IconButton.styleFrom(
|
value: chatTabsStore.mergedMode,
|
||||||
minimumSize: const Size(42, 42),
|
onChanged: (_) {
|
||||||
backgroundColor: isMerged
|
HapticFeedback.selectionClick();
|
||||||
? null
|
chatTabsStore.toggleMergedMode();
|
||||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
},
|
||||||
),
|
child: const Text('Merge chats'),
|
||||||
onPressed: () {
|
),
|
||||||
HapticFeedback.selectionClick();
|
),
|
||||||
chatTabsStore.toggleMergedMode();
|
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) {
|
Widget _buildTab(BuildContext context, int index) {
|
||||||
final tabInfo = chatTabsStore.tabs[index];
|
final tabInfo = chatTabsStore.tabs[index];
|
||||||
final isActive = index == chatTabsStore.activeTabIndex;
|
final isActive = index == chatTabsStore.activeTabIndex;
|
||||||
final isActivated = tabInfo.isActivated;
|
final isSecondary = !tabInfo.isPrimary;
|
||||||
final displayName = getReadableName(
|
final displayName = getReadableName(
|
||||||
tabInfo.displayName,
|
tabInfo.displayName,
|
||||||
tabInfo.channelLogin,
|
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) {
|
InputChip buildChip({VoidCallback? onDeleted}) => InputChip(
|
||||||
return InputChip(
|
avatar: Badge(
|
||||||
avatar: avatar,
|
smallSize: 8,
|
||||||
label: Text(displayName),
|
backgroundColor: theme.colorScheme.primary,
|
||||||
selected: isActive,
|
isLabelVisible: hasUnread,
|
||||||
showCheckmark: false,
|
child: Opacity(
|
||||||
visualDensity: VisualDensity.compact,
|
opacity: dimmed ? 0.5 : 1.0,
|
||||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
child: ProfilePicture(
|
||||||
onPressed: () {
|
userLogin: tabInfo.channelLogin,
|
||||||
if (!isActive) {
|
radius: 12,
|
||||||
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),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
selected: isActive,
|
),
|
||||||
showCheckmark: false,
|
label: Text(
|
||||||
visualDensity: VisualDensity.compact,
|
displayName,
|
||||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
style: dimmed
|
||||||
onPressed: () {
|
? TextStyle(
|
||||||
if (!isActive) {
|
color: theme.textTheme.bodyMedium?.color
|
||||||
HapticFeedback.selectionClick();
|
?.withValues(alpha: 0.5),
|
||||||
chatTabsStore.setActiveTab(index);
|
)
|
||||||
}
|
: null,
|
||||||
},
|
),
|
||||||
onDeleted: () => controller.open(),
|
selected: isActive,
|
||||||
deleteButtonTooltipMessage: 'Tab options',
|
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<Widget> Function(VoidCallback close) itemsBuilder;
|
||||||
|
|
||||||
|
const _AnchoredPopupMenu({
|
||||||
|
required this.anchorBuilder,
|
||||||
|
required this.itemsBuilder,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AnchoredPopupMenu> createState() => _AnchoredPopupMenuState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AnchoredPopupMenuState extends State<_AnchoredPopupMenu>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
final _link = LayerLink();
|
||||||
|
final _portalCtrl = OverlayPortalController();
|
||||||
|
late final _anim = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
reverseDuration: const Duration(milliseconds: 100),
|
||||||
|
vsync: this,
|
||||||
|
)..addStatusListener((status) {
|
||||||
|
if (status == AnimationStatus.dismissed && _portalCtrl.isShowing) {
|
||||||
|
_portalCtrl.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
late final _curve =
|
||||||
|
CurvedAnimation(parent: _anim, curve: Curves.easeOut);
|
||||||
|
late final _scale = Tween<double>(begin: 0.92, end: 1.0).animate(_curve);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_curve.dispose();
|
||||||
|
_anim.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggle() {
|
||||||
|
if (_portalCtrl.isShowing) {
|
||||||
|
_anim.reverse();
|
||||||
|
} else {
|
||||||
|
_portalCtrl.show();
|
||||||
|
_anim.forward();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _close() {
|
||||||
|
if (_portalCtrl.isShowing) _anim.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return CompositedTransformTarget(
|
||||||
|
link: _link,
|
||||||
|
child: OverlayPortal(
|
||||||
|
controller: _portalCtrl,
|
||||||
|
overlayChildBuilder: (overlayContext) {
|
||||||
|
final theme = Theme.of(overlayContext);
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: _close,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
CompositedTransformFollower(
|
||||||
|
link: _link,
|
||||||
|
targetAnchor: Alignment.bottomRight,
|
||||||
|
followerAnchor: Alignment.topRight,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
showWhenUnlinked: false,
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: _anim,
|
||||||
|
child: ScaleTransition(
|
||||||
|
scale: _scale,
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
child: Material(
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: const BorderRadius.all(
|
||||||
|
Radius.circular(12),
|
||||||
|
),
|
||||||
|
side: BorderSide(
|
||||||
|
color: theme.colorScheme.outlineVariant
|
||||||
|
.withValues(alpha: 0.5),
|
||||||
|
width: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: IntrinsicWidth(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: widget.itemsBuilder(_close),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: widget.anchorBuilder(context, _toggle),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ class ChatUserModal extends StatefulWidget {
|
||||||
final String username;
|
final String username;
|
||||||
final String displayName;
|
final String displayName;
|
||||||
final String userId;
|
final String userId;
|
||||||
|
final VoidCallback? onActivateSourceTab;
|
||||||
|
|
||||||
const ChatUserModal({
|
const ChatUserModal({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -21,6 +22,7 @@ class ChatUserModal extends StatefulWidget {
|
||||||
required this.username,
|
required this.username,
|
||||||
required this.displayName,
|
required this.displayName,
|
||||||
required this.userId,
|
required this.userId,
|
||||||
|
this.onActivateSourceTab,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -61,6 +63,7 @@ class _ChatUserModalState extends State<ChatUserModal> {
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Reply',
|
tooltip: 'Reply',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
widget.onActivateSourceTab?.call();
|
||||||
widget.chatStore.textController.text =
|
widget.chatStore.textController.text =
|
||||||
'@${widget.username} ';
|
'@${widget.username} ';
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
|
|
@ -115,6 +118,7 @@ class _ChatUserModalState extends State<ChatUserModal> {
|
||||||
ircMessage: userMessages[index],
|
ircMessage: userMessages[index],
|
||||||
chatStore: widget.chatStore,
|
chatStore: widget.chatStore,
|
||||||
isModal: true,
|
isModal: true,
|
||||||
|
forceTimestamp: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ class ReplyThread extends StatelessWidget {
|
||||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'$replyName: $replyBody',
|
'$replyName: $replyBody',
|
||||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Flexible(
|
Flexible(
|
||||||
|
|
|
||||||
32
lib/screens/channel/video/chat_latency_sync.dart
Normal file
32
lib/screens/channel/video/chat_latency_sync.dart
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
lib/screens/channel/video/native_video.dart
Normal file
106
lib/screens/channel/video/native_video.dart
Normal file
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
lib/screens/channel/video/native_video_player_interface.dart
Normal file
12
lib/screens/channel/video/native_video_player_interface.dart
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
1308
lib/screens/channel/video/native_video_store.dart
Normal file
1308
lib/screens/channel/video/native_video_store.dart
Normal file
File diff suppressed because it is too large
Load diff
497
lib/screens/channel/video/native_video_store.g.dart
Normal file
497
lib/screens/channel/video/native_video_store.g.dart
Normal file
|
|
@ -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<String>? _$streamQualityComputed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get streamQuality => (_$streamQualityComputed ??= Computed<String>(
|
||||||
|
() => 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<String> get availableStreamQualities {
|
||||||
|
_$_availableStreamQualitiesAtom.reportRead();
|
||||||
|
return super._availableStreamQualities;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get _availableStreamQualities => availableStreamQualities;
|
||||||
|
|
||||||
|
@override
|
||||||
|
set _availableStreamQualities(List<String> value) {
|
||||||
|
_$_availableStreamQualitiesAtom.reportWrite(
|
||||||
|
value,
|
||||||
|
super._availableStreamQualities,
|
||||||
|
() {
|
||||||
|
super._availableStreamQualities = value;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$_restrictedStreamQualitiesAtom = Atom(
|
||||||
|
name: 'NativeVideoStoreBase._restrictedStreamQualities',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
List<String> get restrictedStreamQualities {
|
||||||
|
_$_restrictedStreamQualitiesAtom.reportRead();
|
||||||
|
return super._restrictedStreamQualities;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get _restrictedStreamQualities => restrictedStreamQualities;
|
||||||
|
|
||||||
|
@override
|
||||||
|
set _restrictedStreamQualities(List<String> 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<void> _initPlayer() {
|
||||||
|
return _$_initPlayerAsyncAction.run(() => super._initPlayer());
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$updateStreamQualitiesAsyncAction = AsyncAction(
|
||||||
|
'NativeVideoStoreBase.updateStreamQualities',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateStreamQualities() {
|
||||||
|
return _$updateStreamQualitiesAsyncAction.run(
|
||||||
|
() => super.updateStreamQualities(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$setStreamQualityAsyncAction = AsyncAction(
|
||||||
|
'NativeVideoStoreBase.setStreamQuality',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setStreamQuality(String quality) {
|
||||||
|
return _$setStreamQualityAsyncAction.run(
|
||||||
|
() => super.setStreamQuality(quality),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$_setStreamQualityIndexAsyncAction = AsyncAction(
|
||||||
|
'NativeVideoStoreBase._setStreamQualityIndex',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> _setStreamQualityIndex(int index) {
|
||||||
|
return _$_setStreamQualityIndexAsyncAction.run(
|
||||||
|
() => super._setStreamQualityIndex(index),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$updateStreamInfoAsyncAction = AsyncAction(
|
||||||
|
'NativeVideoStoreBase.updateStreamInfo',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateStreamInfo({bool forceUpdate = false}) {
|
||||||
|
return _$updateStreamInfoAsyncAction.run(
|
||||||
|
() => super.updateStreamInfo(forceUpdate: forceUpdate),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$NativeVideoStoreBaseActionController = ActionController(
|
||||||
|
name: 'NativeVideoStoreBase',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> _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<void> 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}
|
||||||
|
''';
|
||||||
|
}
|
||||||
|
}
|
||||||
86
lib/screens/channel/video/stream_info_poller.dart
Normal file
86
lib/screens/channel/video/stream_info_poller.dart
Normal file
|
|
@ -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<StreamInfoResult>? _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<StreamInfoResult?> 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<StreamInfoResult> _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<void>.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,10 +3,12 @@ import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||||
|
import 'package:frosty/constants.dart';
|
||||||
import 'package:frosty/screens/channel/chat/details/chat_users_list.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/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/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/screens/settings/stores/settings_store.dart';
|
||||||
import 'package:frosty/theme.dart';
|
import 'package:frosty/theme.dart';
|
||||||
import 'package:frosty/utils.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.
|
/// Creates a widget containing controls which enable interactions with an underlying [Video] widget.
|
||||||
class VideoOverlay extends StatelessWidget {
|
class VideoOverlay extends StatelessWidget {
|
||||||
final VideoStore videoStore;
|
final VideoPlayerInterface videoStore;
|
||||||
final ChatStore chatStore;
|
final ChatStore chatStore;
|
||||||
final SettingsStore settingsStore;
|
final SettingsStore settingsStore;
|
||||||
|
|
||||||
|
|
@ -88,7 +90,7 @@ class VideoOverlay extends StatelessWidget {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const SectionHeader(
|
const SectionHeader(
|
||||||
'Stream quality',
|
'Quality',
|
||||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
|
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||||
isFirst: true,
|
isFirst: true,
|
||||||
),
|
),
|
||||||
|
|
@ -97,26 +99,35 @@ class VideoOverlay extends StatelessWidget {
|
||||||
builder: (context) => ListView(
|
builder: (context) => ListView(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
primary: false,
|
primary: false,
|
||||||
children: videoStore.availableStreamQualities
|
children: [
|
||||||
.map(
|
...videoStore.availableStreamQualities.map(
|
||||||
(quality) => ListTile(
|
(quality) => ListTile(
|
||||||
trailing: videoStore.streamQuality == quality
|
trailing: videoStore.streamQuality == quality
|
||||||
? const Icon(Icons.check_rounded)
|
? const Icon(Icons.check_rounded)
|
||||||
: null,
|
: null,
|
||||||
title: Text(quality),
|
title: Text(quality),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
videoStore.setStreamQuality(quality);
|
videoStore.setStreamQuality(quality);
|
||||||
SharedPreferences.getInstance().then(
|
SharedPreferences.getInstance().then(
|
||||||
(prefs) => prefs.setString(
|
(prefs) => prefs.setString(
|
||||||
'last_stream_quality',
|
lastStreamQualityKey(videoStore.userLogin),
|
||||||
quality,
|
quality,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
.toList(),
|
// 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,
|
displayName: chatStore.displayName,
|
||||||
showUptime: false,
|
showUptime: false,
|
||||||
showViewerCount: false,
|
showViewerCount: false,
|
||||||
showOfflineIndicator: false,
|
|
||||||
textColor: surfaceColor,
|
textColor: surfaceColor,
|
||||||
isOffline: true,
|
isOffline: true,
|
||||||
isInSharedChatMode:
|
isInSharedChatMode:
|
||||||
|
|
@ -344,32 +354,36 @@ class VideoOverlay extends StatelessWidget {
|
||||||
if (videoStore.settingsStore.fullScreen &&
|
if (videoStore.settingsStore.fullScreen &&
|
||||||
context.isLandscape)
|
context.isLandscape)
|
||||||
chatOverlayButton,
|
chatOverlayButton,
|
||||||
if (!Platform.isIOS || isIPad()) videoSettingsButton,
|
if (!Platform.isIOS ||
|
||||||
|
isIPad() ||
|
||||||
|
videoStore is NativeVideoStore)
|
||||||
|
videoSettingsButton,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Center(
|
if (!videoStore.loading)
|
||||||
child: Tooltip(
|
Center(
|
||||||
message: videoStore.paused ? 'Play' : 'Pause',
|
child: Tooltip(
|
||||||
preferBelow: false,
|
message: videoStore.paused ? 'Play' : 'Pause',
|
||||||
child: IconButton(
|
preferBelow: false,
|
||||||
iconSize: 56,
|
child: IconButton(
|
||||||
icon: Icon(
|
iconSize: 56,
|
||||||
videoStore.paused
|
icon: Icon(
|
||||||
? Icons.play_arrow_rounded
|
videoStore.paused
|
||||||
: Icons.pause_rounded,
|
? Icons.play_arrow_rounded
|
||||||
color: surfaceColor,
|
: Icons.pause_rounded,
|
||||||
shadows: [
|
color: surfaceColor,
|
||||||
Shadow(
|
shadows: [
|
||||||
offset: const Offset(0, 3),
|
Shadow(
|
||||||
blurRadius: 8,
|
offset: const Offset(0, 3),
|
||||||
color: Colors.black.withValues(alpha: 0.6),
|
blurRadius: 8,
|
||||||
),
|
color: Colors.black.withValues(alpha: 0.6),
|
||||||
],
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onPressed: videoStore.handlePausePlay,
|
||||||
),
|
),
|
||||||
onPressed: videoStore.handlePausePlay,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.bottomLeft,
|
alignment: Alignment.bottomLeft,
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|
|
||||||
43
lib/screens/channel/video/video_player_interface.dart
Normal file
43
lib/screens/channel/video/video_player_interface.dart
Normal file
|
|
@ -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<String> 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<String> get restrictedStreamQualities;
|
||||||
|
|
||||||
|
String get streamQuality;
|
||||||
|
String? get latency;
|
||||||
|
|
||||||
|
void handleVideoTap();
|
||||||
|
void handlePausePlay();
|
||||||
|
void handleToggleOverlay();
|
||||||
|
Future<void> handleRefresh();
|
||||||
|
void requestPictureInPicture();
|
||||||
|
void togglePictureInPicture();
|
||||||
|
Future<void> updateStreamQualities();
|
||||||
|
Future<void> setStreamQuality(String quality);
|
||||||
|
Future<void> updateStreamInfo({bool forceUpdate});
|
||||||
|
void handleAppResume();
|
||||||
|
void dispose();
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
// 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 {
|
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(
|
late final _$_pausedAtom = Atom(
|
||||||
name: 'VideoStoreBase._paused',
|
name: 'VideoStoreBase._paused',
|
||||||
context: context,
|
context: context,
|
||||||
|
|
|
||||||
67
lib/screens/channel/video/video_timing_constants.dart
Normal file
67
lib/screens/channel/video/video_timing_constants.dart
Normal file
|
|
@ -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>[
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
@ -166,13 +166,12 @@ class _HomeState extends State<Home> {
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.favorite_border_rounded,
|
Icons.favorite_border_rounded,
|
||||||
color: _homeStore.selectedIndex == 0
|
color: theme.colorScheme.onSurfaceVariant
|
||||||
? theme.colorScheme.onSurface
|
.withValues(alpha: 0.5),
|
||||||
: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
),
|
||||||
selectedIcon: Icon(
|
selectedIcon: Icon(
|
||||||
Icons.favorite_rounded,
|
Icons.favorite_rounded,
|
||||||
color: theme.colorScheme.onSurface,
|
color: theme.colorScheme.primary,
|
||||||
),
|
),
|
||||||
label: 'Following',
|
label: 'Following',
|
||||||
tooltip: 'Following',
|
tooltip: 'Following',
|
||||||
|
|
@ -180,15 +179,12 @@ class _HomeState extends State<Home> {
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.arrow_upward_rounded,
|
Icons.arrow_upward_rounded,
|
||||||
color:
|
color: theme.colorScheme.onSurfaceVariant
|
||||||
_homeStore.selectedIndex ==
|
.withValues(alpha: 0.5),
|
||||||
(_authStore.isLoggedIn ? 1 : 0)
|
|
||||||
? theme.colorScheme.onSurface
|
|
||||||
: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
),
|
||||||
selectedIcon: Icon(
|
selectedIcon: Icon(
|
||||||
Icons.arrow_upward_rounded,
|
Icons.arrow_upward_rounded,
|
||||||
color: theme.colorScheme.onSurface,
|
color: theme.colorScheme.primary,
|
||||||
),
|
),
|
||||||
label: 'Top',
|
label: 'Top',
|
||||||
tooltip: 'Top',
|
tooltip: 'Top',
|
||||||
|
|
@ -196,15 +192,12 @@ class _HomeState extends State<Home> {
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.search_rounded,
|
Icons.search_rounded,
|
||||||
color:
|
color: theme.colorScheme.onSurfaceVariant
|
||||||
_homeStore.selectedIndex ==
|
.withValues(alpha: 0.5),
|
||||||
(_authStore.isLoggedIn ? 2 : 1)
|
|
||||||
? theme.colorScheme.onSurface
|
|
||||||
: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
),
|
||||||
selectedIcon: Icon(
|
selectedIcon: Icon(
|
||||||
Icons.search_rounded,
|
Icons.search_rounded,
|
||||||
color: theme.colorScheme.onSurface,
|
color: theme.colorScheme.primary,
|
||||||
),
|
),
|
||||||
label: 'Search',
|
label: 'Search',
|
||||||
tooltip: 'Search',
|
tooltip: 'Search',
|
||||||
|
|
|
||||||
|
|
@ -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/account/account_options.dart';
|
||||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
import 'package:frosty/utils/modal_bottom_sheet.dart';
|
import 'package:frosty/utils/modal_bottom_sheet.dart';
|
||||||
|
import 'package:frosty/widgets/frosty_dialog.dart';
|
||||||
import 'package:frosty/widgets/profile_picture.dart';
|
import 'package:frosty/widgets/profile_picture.dart';
|
||||||
|
|
||||||
class ProfileCard extends StatelessWidget {
|
class ProfileCard extends StatelessWidget {
|
||||||
|
|
@ -39,12 +40,69 @@ class ProfileCard extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (authStore.isLoggedIn && authStore.user.details != null) {
|
if (authStore.isLoggedIn && authStore.user.details != null) {
|
||||||
|
final hasToken = authStore.gqlToken != null;
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: ProfilePicture(
|
leading: ProfilePicture(
|
||||||
userLogin: authStore.user.details!.login,
|
userLogin: authStore.user.details!.login,
|
||||||
radius: 12,
|
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),
|
trailing: const Icon(Icons.chevron_right_rounded),
|
||||||
onTap: () => _showAccountOptionsModalBottomSheet(context),
|
onTap: () => _showAccountOptionsModalBottomSheet(context),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,347 +1,389 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||||
import 'package:frosty/constants.dart';
|
import 'package:frosty/constants.dart';
|
||||||
import 'package:frosty/screens/settings/stores/settings_store.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_select.dart';
|
||||||
import 'package:frosty/screens/settings/widgets/settings_list_slider.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_list_switch.dart';
|
||||||
import 'package:frosty/screens/settings/widgets/settings_muted_words.dart';
|
import 'package:frosty/screens/settings/widgets/settings_muted_words.dart';
|
||||||
import 'package:frosty/utils/context_extensions.dart';
|
import 'package:frosty/utils/context_extensions.dart';
|
||||||
import 'package:frosty/widgets/frosty_cached_network_image.dart';
|
import 'package:frosty/widgets/frosty_cached_network_image.dart';
|
||||||
import 'package:frosty/widgets/section_header.dart';
|
import 'package:frosty/widgets/section_header.dart';
|
||||||
import 'package:frosty/widgets/settings_page_layout.dart';
|
import 'package:frosty/widgets/settings_page_layout.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class ChatSettings extends StatefulWidget {
|
class ChatSettings extends StatelessWidget {
|
||||||
final SettingsStore settingsStore;
|
final SettingsStore settingsStore;
|
||||||
|
|
||||||
const ChatSettings({super.key, required this.settingsStore});
|
const ChatSettings({super.key, required this.settingsStore});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ChatSettings> createState() => _ChatSettingsState();
|
Widget build(BuildContext context) {
|
||||||
}
|
return Observer(
|
||||||
|
builder: (context) => SettingsPageLayout(
|
||||||
class _ChatSettingsState extends State<ChatSettings> {
|
children: [
|
||||||
var showPreview = false;
|
const SectionHeader('General', isFirst: true),
|
||||||
|
SettingsListSwitch(
|
||||||
@override
|
title: 'Keep screen on',
|
||||||
Widget build(BuildContext context) {
|
subtitle: const Text(
|
||||||
final settingsStore = widget.settingsStore;
|
'Prevents the screen from sleeping while a channel is open.',
|
||||||
|
),
|
||||||
return Observer(
|
value: settingsStore.keepScreenAwake,
|
||||||
builder: (context) => SettingsPageLayout(
|
onChanged: (newValue) =>
|
||||||
children: [
|
settingsStore.keepScreenAwake = newValue,
|
||||||
const SectionHeader('Message sizing', isFirst: true),
|
),
|
||||||
ExpansionTile(
|
SettingsListSwitch(
|
||||||
title: const Text('Preview'),
|
title: 'Autocomplete',
|
||||||
children: [
|
subtitle: const Text(
|
||||||
Container(
|
'Shows matching emotes and mentions while typing.',
|
||||||
width: double.infinity,
|
),
|
||||||
padding: const EdgeInsets.all(16),
|
value: settingsStore.autocomplete,
|
||||||
child: DefaultTextStyle(
|
onChanged: (newValue) =>
|
||||||
style: DefaultTextStyle.of(
|
settingsStore.autocomplete = newValue,
|
||||||
context,
|
),
|
||||||
).style.copyWith(fontSize: settingsStore.fontSize),
|
SettingsListSwitch(
|
||||||
child: Column(
|
title: 'Load recent messages',
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
subtitle: Text.rich(
|
||||||
children: [
|
TextSpan(
|
||||||
Text.rich(
|
text:
|
||||||
TextSpan(
|
'Loads historical recent messages in chat through a third-party API service at ',
|
||||||
children: [
|
children: [
|
||||||
WidgetSpan(
|
TextSpan(
|
||||||
alignment: PlaceholderAlignment.middle,
|
text: 'https://recent-messages.robotty.de/',
|
||||||
child: FrostyCachedNetworkImage(
|
style: TextStyle(
|
||||||
imageUrl:
|
color: context.colorScheme.primary,
|
||||||
'https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/3',
|
decoration: TextDecoration.underline,
|
||||||
height:
|
decorationColor: context.colorScheme.primary,
|
||||||
defaultBadgeSize * settingsStore.badgeScale,
|
),
|
||||||
width:
|
recognizer: TapGestureRecognizer()
|
||||||
defaultBadgeSize * settingsStore.badgeScale,
|
..onTap = () => launchUrl(
|
||||||
),
|
Uri.parse(
|
||||||
),
|
'https://recent-messages.robotty.de/',
|
||||||
const TextSpan(text: ' Badge and emote preview. '),
|
),
|
||||||
WidgetSpan(
|
mode: settingsStore.launchUrlExternal
|
||||||
alignment: PlaceholderAlignment.middle,
|
? LaunchMode.externalApplication
|
||||||
child: FrostyCachedNetworkImage(
|
: LaunchMode.inAppBrowserView,
|
||||||
imageUrl:
|
),
|
||||||
'https://static-cdn.jtvnw.net/emoticons/v2/425618/default/dark/3.0',
|
),
|
||||||
height:
|
],
|
||||||
defaultEmoteSize * settingsStore.emoteScale,
|
),
|
||||||
width:
|
),
|
||||||
defaultEmoteSize * settingsStore.emoteScale,
|
value: settingsStore.showRecentMessages,
|
||||||
),
|
onChanged: (newValue) =>
|
||||||
),
|
settingsStore.showRecentMessages = newValue,
|
||||||
],
|
),
|
||||||
),
|
const SectionHeader('Message sizing'),
|
||||||
textScaler: settingsStore.messageScale.textScaler,
|
ExpansionTile(
|
||||||
),
|
title: const Text('Preview'),
|
||||||
SizedBox(height: settingsStore.messageSpacing),
|
children: [
|
||||||
Text(
|
Container(
|
||||||
'Hello! Here\'s a text preview.',
|
width: double.infinity,
|
||||||
textScaler: settingsStore.messageScale.textScaler,
|
padding: const EdgeInsets.all(16),
|
||||||
),
|
child: DefaultTextStyle(
|
||||||
SizedBox(height: settingsStore.messageSpacing),
|
style: DefaultTextStyle.of(
|
||||||
Text(
|
context,
|
||||||
'And another for spacing without an emote!',
|
).style.copyWith(fontSize: settingsStore.fontSize),
|
||||||
textScaler: settingsStore.messageScale.textScaler,
|
child: Column(
|
||||||
),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
],
|
children: [
|
||||||
),
|
Text.rich(
|
||||||
),
|
TextSpan(
|
||||||
),
|
children: [
|
||||||
],
|
WidgetSpan(
|
||||||
),
|
alignment: PlaceholderAlignment.middle,
|
||||||
SettingsListSlider(
|
child: FrostyCachedNetworkImage(
|
||||||
title: 'Badge scale',
|
imageUrl:
|
||||||
trailing: '${settingsStore.badgeScale.toStringAsFixed(2)}x',
|
'https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/3',
|
||||||
value: settingsStore.badgeScale,
|
height:
|
||||||
min: 0.25,
|
defaultBadgeSize *
|
||||||
max: 3.0,
|
settingsStore.badgeScale,
|
||||||
divisions: 11,
|
width:
|
||||||
onChanged: (newValue) => settingsStore.badgeScale = newValue,
|
defaultBadgeSize *
|
||||||
),
|
settingsStore.badgeScale,
|
||||||
SettingsListSlider(
|
),
|
||||||
title: 'Emote scale',
|
),
|
||||||
trailing: '${settingsStore.emoteScale.toStringAsFixed(2)}x',
|
const TextSpan(
|
||||||
value: settingsStore.emoteScale,
|
text: ' Badge and emote preview. ',
|
||||||
min: 0.25,
|
),
|
||||||
max: 3.0,
|
WidgetSpan(
|
||||||
divisions: 11,
|
alignment: PlaceholderAlignment.middle,
|
||||||
onChanged: (newValue) => settingsStore.emoteScale = newValue,
|
child: FrostyCachedNetworkImage(
|
||||||
),
|
imageUrl:
|
||||||
SettingsListSlider(
|
'https://static-cdn.jtvnw.net/emoticons/v2/425618/default/dark/3.0',
|
||||||
title: 'Message scale',
|
height:
|
||||||
trailing: '${settingsStore.messageScale.toStringAsFixed(2)}x',
|
defaultEmoteSize *
|
||||||
value: settingsStore.messageScale,
|
settingsStore.emoteScale,
|
||||||
min: 0.5,
|
width:
|
||||||
max: 2.0,
|
defaultEmoteSize *
|
||||||
divisions: 6,
|
settingsStore.emoteScale,
|
||||||
onChanged: (newValue) => settingsStore.messageScale = newValue,
|
),
|
||||||
),
|
),
|
||||||
SettingsListSlider(
|
],
|
||||||
title: 'Message spacing',
|
),
|
||||||
trailing: '${settingsStore.messageSpacing.toStringAsFixed(0)}px',
|
textScaler: settingsStore.messageScale.textScaler,
|
||||||
value: settingsStore.messageSpacing,
|
),
|
||||||
max: 30.0,
|
SizedBox(height: settingsStore.messageSpacing),
|
||||||
divisions: 15,
|
Text(
|
||||||
onChanged: (newValue) => settingsStore.messageSpacing = newValue,
|
'Hello! Here\'s a text preview.',
|
||||||
),
|
textScaler: settingsStore.messageScale.textScaler,
|
||||||
SettingsListSlider(
|
),
|
||||||
title: 'Font size',
|
SizedBox(height: settingsStore.messageSpacing),
|
||||||
trailing: settingsStore.fontSize.toInt().toString(),
|
Text(
|
||||||
value: settingsStore.fontSize,
|
'And another for spacing without an emote!',
|
||||||
min: 5,
|
textScaler: settingsStore.messageScale.textScaler,
|
||||||
max: 20,
|
),
|
||||||
divisions: 15,
|
],
|
||||||
onChanged: (newValue) => settingsStore.fontSize = newValue,
|
),
|
||||||
),
|
),
|
||||||
const SectionHeader('Message appearance'),
|
),
|
||||||
SettingsListSwitch(
|
],
|
||||||
title: 'Show deleted messages',
|
),
|
||||||
subtitle: const Text(
|
SettingsListSlider(
|
||||||
'Restores the original message of deleted messages.',
|
title: 'Badge scale',
|
||||||
),
|
trailing: '${settingsStore.badgeScale.toStringAsFixed(2)}x',
|
||||||
value: settingsStore.showDeletedMessages,
|
value: settingsStore.badgeScale,
|
||||||
onChanged: (newValue) =>
|
min: 0.25,
|
||||||
settingsStore.showDeletedMessages = newValue,
|
max: 3.0,
|
||||||
),
|
divisions: 11,
|
||||||
SettingsListSwitch(
|
onChanged: (newValue) => settingsStore.badgeScale = newValue,
|
||||||
title: 'Show message dividers',
|
),
|
||||||
value: settingsStore.showChatMessageDividers,
|
SettingsListSlider(
|
||||||
onChanged: (newValue) =>
|
title: 'Emote scale',
|
||||||
settingsStore.showChatMessageDividers = newValue,
|
trailing: '${settingsStore.emoteScale.toStringAsFixed(2)}x',
|
||||||
),
|
value: settingsStore.emoteScale,
|
||||||
SettingsListSelect(
|
min: 0.25,
|
||||||
title: 'Message timestamps',
|
max: 3.0,
|
||||||
selectedOption: timestampNames[settingsStore.timestampType.index],
|
divisions: 11,
|
||||||
options: timestampNames,
|
onChanged: (newValue) => settingsStore.emoteScale = newValue,
|
||||||
onChanged: (newValue) => settingsStore.timestampType =
|
),
|
||||||
TimestampType.values[timestampNames.indexOf(newValue)],
|
SettingsListSlider(
|
||||||
),
|
title: 'Message scale',
|
||||||
const SectionHeader('Delay and latency'),
|
trailing: '${settingsStore.messageScale.toStringAsFixed(2)}x',
|
||||||
SettingsListSwitch(
|
value: settingsStore.messageScale,
|
||||||
title: 'Sync message delay and stream latency',
|
min: 0.5,
|
||||||
value: settingsStore.autoSyncChatDelay,
|
max: 2.0,
|
||||||
onChanged: (newValue) => settingsStore.autoSyncChatDelay = newValue,
|
divisions: 6,
|
||||||
),
|
onChanged: (newValue) => settingsStore.messageScale = newValue,
|
||||||
if (!settingsStore.autoSyncChatDelay)
|
),
|
||||||
SettingsListSlider(
|
SettingsListSlider(
|
||||||
title: 'Message delay',
|
title: 'Message spacing',
|
||||||
trailing: '${settingsStore.chatDelay.toInt()} seconds',
|
trailing:
|
||||||
subtitle:
|
'${settingsStore.messageSpacing.toStringAsFixed(0)}px',
|
||||||
'Adds a delay before each message is rendered in chat. ${Platform.isIOS ? '15 seconds is recommended for iOS.' : ''}',
|
value: settingsStore.messageSpacing,
|
||||||
value: settingsStore.chatDelay,
|
max: 30.0,
|
||||||
max: 30.0,
|
divisions: 15,
|
||||||
divisions: 30,
|
onChanged: (newValue) =>
|
||||||
onChanged: (newValue) => settingsStore.chatDelay = newValue,
|
settingsStore.messageSpacing = newValue,
|
||||||
),
|
),
|
||||||
const SectionHeader('Alerts'),
|
SettingsListSlider(
|
||||||
SettingsListSwitch(
|
title: 'Font size',
|
||||||
title: 'Highlight first time chatters',
|
trailing: settingsStore.fontSize.toInt().toString(),
|
||||||
value: settingsStore.highlightFirstTimeChatter,
|
value: settingsStore.fontSize,
|
||||||
onChanged: (newValue) =>
|
min: 5,
|
||||||
settingsStore.highlightFirstTimeChatter = newValue,
|
max: 20,
|
||||||
),
|
divisions: 15,
|
||||||
SettingsListSwitch(
|
onChanged: (newValue) => settingsStore.fontSize = newValue,
|
||||||
title: 'Show notices',
|
),
|
||||||
subtitle: const Text(
|
const SectionHeader('Message appearance'),
|
||||||
'Shows notices such as subs and re-subs, announcements, and raids.',
|
SettingsListSwitch(
|
||||||
),
|
title: 'Show deleted messages',
|
||||||
value: settingsStore.showUserNotices,
|
subtitle: const Text(
|
||||||
onChanged: (newValue) => settingsStore.showUserNotices = newValue,
|
'Restores the original message of deleted messages.',
|
||||||
),
|
),
|
||||||
const SectionHeader('Layout'),
|
value: settingsStore.showDeletedMessages,
|
||||||
SettingsListSwitch(
|
onChanged: (newValue) =>
|
||||||
title: 'Move emote menu button left',
|
settingsStore.showDeletedMessages = newValue,
|
||||||
subtitle: const Text(
|
),
|
||||||
'Places the emote menu button on the left side to avoid accidental presses.',
|
SettingsListSwitch(
|
||||||
),
|
title: 'Show message dividers',
|
||||||
value: settingsStore.emoteMenuButtonOnLeft,
|
value: settingsStore.showChatMessageDividers,
|
||||||
onChanged: (newValue) =>
|
onChanged: (newValue) =>
|
||||||
settingsStore.emoteMenuButtonOnLeft = newValue,
|
settingsStore.showChatMessageDividers = newValue,
|
||||||
),
|
),
|
||||||
SettingsListSwitch(
|
SettingsListSelect(
|
||||||
title: 'Persist chat tabs',
|
title: 'Timestamps',
|
||||||
subtitle: const Text(
|
selectedOption:
|
||||||
'Secondary chat tabs are remembered when switching channels.',
|
timestampNames[settingsStore.timestampType.index],
|
||||||
),
|
options: timestampNames,
|
||||||
value: settingsStore.persistChatTabs,
|
onChanged: (newValue) => settingsStore.timestampType =
|
||||||
onChanged: (newValue) {
|
TimestampType.values[timestampNames.indexOf(newValue)],
|
||||||
settingsStore.persistChatTabs = newValue;
|
),
|
||||||
if (!newValue) {
|
SettingsListSwitch(
|
||||||
settingsStore.secondaryTabs = [];
|
title: 'Show timestamps on historical messages',
|
||||||
}
|
subtitle: const Text(
|
||||||
},
|
'Always show timestamps on messages loaded from chat history, '
|
||||||
),
|
'even when timestamps are disabled above.',
|
||||||
const SectionHeader('Landscape mode'),
|
),
|
||||||
SettingsListSwitch(
|
value: settingsStore.showHistoricalTimestamps,
|
||||||
title: 'Move chat left',
|
onChanged: (newValue) =>
|
||||||
value: settingsStore.landscapeChatLeftSide,
|
settingsStore.showHistoricalTimestamps = newValue,
|
||||||
onChanged: (newValue) =>
|
),
|
||||||
settingsStore.landscapeChatLeftSide = newValue,
|
SettingsListSwitch(
|
||||||
),
|
title: 'Focus current channel',
|
||||||
SettingsListSwitch(
|
subtitle: const Text(
|
||||||
title: 'Force vertical chat',
|
'Fades messages from other channels in shared chat and '
|
||||||
subtitle: const Text(
|
'merged views, so the current channel stands out.',
|
||||||
'Intended for tablets and other larger displays.',
|
),
|
||||||
),
|
value: settingsStore.focusCurrentChannel,
|
||||||
value: settingsStore.landscapeForceVerticalChat,
|
onChanged: (newValue) =>
|
||||||
onChanged: (newValue) =>
|
settingsStore.focusCurrentChannel = newValue,
|
||||||
settingsStore.landscapeForceVerticalChat = newValue,
|
),
|
||||||
),
|
const SectionHeader('Delay'),
|
||||||
SettingsListSelect(
|
SettingsListSwitch(
|
||||||
title: 'Fill notch side',
|
title: 'Auto-sync chat delay',
|
||||||
subtitle:
|
value: settingsStore.autoSyncChatDelay,
|
||||||
'Overrides and fills the available space in devices with a display notch.',
|
onChanged: (newValue) =>
|
||||||
selectedOption:
|
settingsStore.autoSyncChatDelay = newValue,
|
||||||
landscapeCutoutNames[settingsStore.landscapeCutout.index],
|
),
|
||||||
options: landscapeCutoutNames,
|
if (!settingsStore.autoSyncChatDelay)
|
||||||
onChanged: (newValue) => settingsStore.landscapeCutout =
|
SettingsListSlider(
|
||||||
LandscapeCutoutType.values[landscapeCutoutNames.indexOf(
|
title: 'Chat delay',
|
||||||
newValue,
|
trailing: '${settingsStore.chatDelay.toInt()} seconds',
|
||||||
)],
|
subtitle:
|
||||||
),
|
'Adds a delay before each message is rendered in chat. ${Platform.isIOS ? '15 seconds is recommended for iOS.' : ''}',
|
||||||
SettingsListSlider(
|
value: settingsStore.chatDelay,
|
||||||
title: 'Chat overlay opacity',
|
max: 30.0,
|
||||||
trailing:
|
divisions: 30,
|
||||||
'${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%',
|
onChanged: (newValue) =>
|
||||||
subtitle:
|
settingsStore.chatDelay = newValue,
|
||||||
'Sets the opacity (transparency) of the overlay chat in fullscreen mode.',
|
),
|
||||||
value: settingsStore.fullScreenChatOverlayOpacity,
|
const SectionHeader('Alerts'),
|
||||||
divisions: 10,
|
SettingsListSwitch(
|
||||||
onChanged: (newValue) =>
|
title: 'Highlight first-time chatters',
|
||||||
settingsStore.fullScreenChatOverlayOpacity = newValue,
|
value: settingsStore.highlightFirstTimeChatter,
|
||||||
),
|
onChanged: (newValue) =>
|
||||||
const SectionHeader('Muted keywords'),
|
settingsStore.highlightFirstTimeChatter = newValue,
|
||||||
SettingsMutedWords(settingsStore: settingsStore),
|
),
|
||||||
SettingsListSwitch(
|
SettingsListSwitch(
|
||||||
title: 'Match whole words',
|
title: 'Show notices',
|
||||||
subtitle: const Text(
|
subtitle: const Text(
|
||||||
'Only matches whole words instead of partial matches.',
|
'Shows notices such as subs, announcements, and raids.',
|
||||||
),
|
),
|
||||||
value: settingsStore.matchWholeWord,
|
value: settingsStore.showUserNotices,
|
||||||
onChanged: (newValue) => settingsStore.matchWholeWord = newValue,
|
onChanged: (newValue) =>
|
||||||
),
|
settingsStore.showUserNotices = newValue,
|
||||||
const SectionHeader('Autocomplete'),
|
),
|
||||||
SettingsListSwitch(
|
const SectionHeader('Layout'),
|
||||||
title: 'Show autocomplete bar',
|
SettingsListSwitch(
|
||||||
subtitle: const Text(
|
title: 'Emote menu on left',
|
||||||
'Shows a bar containing matching emotes and mentions while typing.',
|
subtitle: const Text(
|
||||||
),
|
'Places the emote menu button on the left side.',
|
||||||
value: settingsStore.autocomplete,
|
),
|
||||||
onChanged: (newValue) => settingsStore.autocomplete = newValue,
|
value: settingsStore.emoteMenuButtonOnLeft,
|
||||||
),
|
onChanged: (newValue) =>
|
||||||
const SectionHeader('Emotes and badges'),
|
settingsStore.emoteMenuButtonOnLeft = newValue,
|
||||||
SettingsListSwitch(
|
),
|
||||||
title: 'Show Twitch emotes',
|
SettingsListSwitch(
|
||||||
value: settingsStore.showTwitchEmotes,
|
title: 'Remember chat tabs',
|
||||||
onChanged: (newValue) => settingsStore.showTwitchEmotes = newValue,
|
subtitle: const Text(
|
||||||
),
|
'Secondary chat tabs are kept when switching channels.',
|
||||||
SettingsListSwitch(
|
),
|
||||||
title: 'Show Twitch badges',
|
value: settingsStore.persistChatTabs,
|
||||||
value: settingsStore.showTwitchBadges,
|
onChanged: (newValue) {
|
||||||
onChanged: (newValue) => settingsStore.showTwitchBadges = newValue,
|
settingsStore.persistChatTabs = newValue;
|
||||||
),
|
if (!newValue) {
|
||||||
SettingsListSwitch(
|
settingsStore.secondaryTabs = [];
|
||||||
title: 'Show 7TV emotes',
|
}
|
||||||
value: settingsStore.show7TVEmotes,
|
},
|
||||||
onChanged: (newValue) => settingsStore.show7TVEmotes = newValue,
|
),
|
||||||
),
|
const SectionHeader('Landscape'),
|
||||||
SettingsListSwitch(
|
SettingsListSwitch(
|
||||||
title: 'Show BTTV emotes',
|
title: 'Chat on left side',
|
||||||
value: settingsStore.showBTTVEmotes,
|
value: settingsStore.landscapeChatLeftSide,
|
||||||
onChanged: (newValue) => settingsStore.showBTTVEmotes = newValue,
|
onChanged: (newValue) =>
|
||||||
),
|
settingsStore.landscapeChatLeftSide = newValue,
|
||||||
SettingsListSwitch(
|
),
|
||||||
title: 'Show BTTV badges',
|
SettingsListSwitch(
|
||||||
value: settingsStore.showBTTVBadges,
|
title: 'Force vertical chat',
|
||||||
onChanged: (newValue) => settingsStore.showBTTVBadges = newValue,
|
subtitle: const Text(
|
||||||
),
|
'Intended for tablets and larger displays.',
|
||||||
SettingsListSwitch(
|
),
|
||||||
title: 'Show FFZ emotes',
|
value: settingsStore.landscapeForceVerticalChat,
|
||||||
value: settingsStore.showFFZEmotes,
|
onChanged: (newValue) =>
|
||||||
onChanged: (newValue) => settingsStore.showFFZEmotes = newValue,
|
settingsStore.landscapeForceVerticalChat = newValue,
|
||||||
),
|
),
|
||||||
SettingsListSwitch(
|
SettingsListSelect(
|
||||||
title: 'Show FFZ badges',
|
title: 'Notch fill',
|
||||||
value: settingsStore.showFFZBadges,
|
subtitle:
|
||||||
onChanged: (newValue) => settingsStore.showFFZBadges = newValue,
|
'Fills the display cutout area on the selected side.',
|
||||||
),
|
selectedOption:
|
||||||
const SectionHeader('Recent messages'),
|
landscapeCutoutNames[settingsStore.landscapeCutout.index],
|
||||||
SettingsListSwitch(
|
options: landscapeCutoutNames,
|
||||||
title: 'Show historical recent messages',
|
onChanged: (newValue) => settingsStore.landscapeCutout =
|
||||||
subtitle: Text.rich(
|
LandscapeCutoutType.values[landscapeCutoutNames.indexOf(
|
||||||
TextSpan(
|
newValue,
|
||||||
text:
|
)],
|
||||||
'Loads historical recent messages in chat through a third-party API service at ',
|
),
|
||||||
children: [
|
SettingsListSlider(
|
||||||
TextSpan(
|
title: 'Overlay chat opacity',
|
||||||
text: 'https://recent-messages.robotty.de/',
|
trailing:
|
||||||
style: TextStyle(
|
'${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%',
|
||||||
color: context.colorScheme.primary,
|
subtitle:
|
||||||
decoration: TextDecoration.underline,
|
'Opacity of the chat overlay in fullscreen mode.',
|
||||||
decorationColor: context.colorScheme.primary,
|
value: settingsStore.fullScreenChatOverlayOpacity,
|
||||||
),
|
divisions: 10,
|
||||||
recognizer: TapGestureRecognizer()
|
onChanged: (newValue) =>
|
||||||
..onTap = () => launchUrl(
|
settingsStore.fullScreenChatOverlayOpacity = newValue,
|
||||||
Uri.parse('https://recent-messages.robotty.de/'),
|
),
|
||||||
mode: settingsStore.launchUrlExternal
|
const SectionHeader('Filtering'),
|
||||||
? LaunchMode.externalApplication
|
SettingsMutedWords(settingsStore: settingsStore),
|
||||||
: LaunchMode.inAppBrowserView,
|
SettingsListSwitch(
|
||||||
),
|
title: 'Match whole words',
|
||||||
),
|
subtitle: const Text(
|
||||||
],
|
'Only matches whole words instead of partial matches.',
|
||||||
),
|
),
|
||||||
),
|
value: settingsStore.matchWholeWord,
|
||||||
value: settingsStore.showRecentMessages,
|
onChanged: (newValue) =>
|
||||||
onChanged: (newValue) =>
|
settingsStore.matchWholeWord = newValue,
|
||||||
settingsStore.showRecentMessages = 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,12 @@ class GeneralSettings extends StatelessWidget {
|
||||||
AccentColorSetting(settingsStore: settingsStore),
|
AccentColorSetting(settingsStore: settingsStore),
|
||||||
const SectionHeader('Stream card'),
|
const SectionHeader('Stream card'),
|
||||||
SettingsListSwitch(
|
SettingsListSwitch(
|
||||||
title: 'Use large stream card',
|
title: 'Large stream cards',
|
||||||
value: settingsStore.largeStreamCard,
|
value: settingsStore.largeStreamCard,
|
||||||
onChanged: (newValue) => settingsStore.largeStreamCard = newValue,
|
onChanged: (newValue) => settingsStore.largeStreamCard = newValue,
|
||||||
),
|
),
|
||||||
SettingsListSwitch(
|
SettingsListSwitch(
|
||||||
title: 'Show thumbnail',
|
title: 'Show thumbnails',
|
||||||
value: settingsStore.showThumbnails,
|
value: settingsStore.showThumbnails,
|
||||||
onChanged: (newValue) => settingsStore.showThumbnails = newValue,
|
onChanged: (newValue) => settingsStore.showThumbnails = newValue,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import 'package:frosty/apis/twitch_api.dart';
|
||||||
import 'package:frosty/constants.dart';
|
import 'package:frosty/constants.dart';
|
||||||
import 'package:frosty/main.dart';
|
import 'package:frosty/main.dart';
|
||||||
import 'package:frosty/screens/settings/stores/user_store.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:frosty/widgets/frosty_dialog.dart';
|
||||||
import 'package:mobx/mobx.dart';
|
import 'package:mobx/mobx.dart';
|
||||||
import 'package:webview_flutter/webview_flutter.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.
|
/// The shared_preferences key for the user token.
|
||||||
static const _userTokenKey = '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.
|
/// The Twitch API service for making requests.
|
||||||
final TwitchApi twitchApi;
|
final TwitchApi twitchApi;
|
||||||
|
|
||||||
|
|
@ -49,6 +53,10 @@ abstract class AuthBase with Store {
|
||||||
@readonly
|
@readonly
|
||||||
String? _token;
|
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.
|
/// Whether the user is logged in or not.
|
||||||
@readonly
|
@readonly
|
||||||
var _isLoggedIn = false;
|
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".
|
// 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.
|
// 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') {
|
if (request.url == 'https://www.twitch.tv/?no-reload=true') {
|
||||||
|
_extractGqlToken();
|
||||||
if (routeAfter != null) {
|
if (routeAfter != null) {
|
||||||
navigatorKey.currentState?.pop();
|
navigatorKey.currentState?.pop();
|
||||||
navigatorKey.currentState?.push(
|
navigatorKey.currentState?.push(
|
||||||
|
|
@ -211,12 +220,36 @@ abstract class AuthBase with Store {
|
||||||
|
|
||||||
AuthBase({required this.twitchApi}) : user = UserStore(twitchApi: twitchApi);
|
AuthBase({required this.twitchApi}) : user = UserStore(twitchApi: twitchApi);
|
||||||
|
|
||||||
|
Future<void> _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<void> invalidateGqlToken() async {
|
||||||
|
_gqlToken = null;
|
||||||
|
await _storage.delete(key: _gqlTokenKey);
|
||||||
|
}
|
||||||
|
|
||||||
/// Initialize by retrieving a token if it does not already exist.
|
/// Initialize by retrieving a token if it does not already exist.
|
||||||
@action
|
@action
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
try {
|
try {
|
||||||
// Read and set the currently stored user token, if any.
|
// Read and set the currently stored user token, if any.
|
||||||
_token = await _storage.read(key: _userTokenKey);
|
_token = await _storage.read(key: _userTokenKey);
|
||||||
|
_gqlToken = await _storage.read(key: _gqlTokenKey);
|
||||||
|
|
||||||
// If the token does not exist, get the default token.
|
// If the token does not exist, get the default token.
|
||||||
// Otherwise, log in.
|
// Otherwise, log in.
|
||||||
|
|
@ -248,6 +281,10 @@ abstract class AuthBase with Store {
|
||||||
if (user.details != null) {
|
if (user.details != null) {
|
||||||
_isLoggedIn = true;
|
_isLoggedIn = true;
|
||||||
_stopReconnectLoop();
|
_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<void> logout() async {
|
Future<void> logout() async {
|
||||||
try {
|
try {
|
||||||
_stopReconnectLoop();
|
_stopReconnectLoop();
|
||||||
// Delete the existing user token.
|
// Delete the existing user token and GQL token.
|
||||||
await _storage.delete(key: _userTokenKey);
|
await _storage.delete(key: _userTokenKey);
|
||||||
|
await _storage.delete(key: _gqlTokenKey);
|
||||||
_token = null;
|
_token = null;
|
||||||
|
_gqlToken = null;
|
||||||
|
|
||||||
// Clear the user info.
|
// Clear the user info.
|
||||||
user.dispose();
|
user.dispose();
|
||||||
|
|
|
||||||
|
|
@ -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(
|
late final _$_isLoggedInAtom = Atom(
|
||||||
name: 'AuthBase._isLoggedIn',
|
name: 'AuthBase._isLoggedIn',
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -72,6 +92,18 @@ mixin _$AuthStore on AuthBase, Store {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
late final _$invalidateGqlTokenAsyncAction = AsyncAction(
|
||||||
|
'AuthBase.invalidateGqlToken',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> invalidateGqlToken() {
|
||||||
|
return _$invalidateGqlTokenAsyncAction.run(
|
||||||
|
() => super.invalidateGqlToken(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
late final _$initAsyncAction = AsyncAction('AuthBase.init', context: context);
|
late final _$initAsyncAction = AsyncAction('AuthBase.init', context: context);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -65,9 +65,8 @@ abstract class _SettingsStoreBase with Store {
|
||||||
static const defaultShowVideo = true;
|
static const defaultShowVideo = true;
|
||||||
static const defaultDefaultToHighestQuality = false;
|
static const defaultDefaultToHighestQuality = false;
|
||||||
static const defaultUseTextureRendering = true;
|
static const defaultUseTextureRendering = true;
|
||||||
static const defaultKeepScreenAwake = true;
|
static const defaultUseNativePlayer = false;
|
||||||
|
|
||||||
// Overlay defaults
|
|
||||||
static const defaultShowOverlay = true;
|
static const defaultShowOverlay = true;
|
||||||
static const defaultToggleableOverlay = false;
|
static const defaultToggleableOverlay = false;
|
||||||
static const defaultShowLatency = false;
|
static const defaultShowLatency = false;
|
||||||
|
|
@ -85,9 +84,9 @@ abstract class _SettingsStoreBase with Store {
|
||||||
@observable
|
@observable
|
||||||
var useTextureRendering = defaultUseTextureRendering;
|
var useTextureRendering = defaultUseTextureRendering;
|
||||||
|
|
||||||
@JsonKey(defaultValue: defaultKeepScreenAwake)
|
@JsonKey(defaultValue: defaultUseNativePlayer)
|
||||||
@observable
|
@observable
|
||||||
var keepScreenAwake = defaultKeepScreenAwake;
|
var useNativePlayer = defaultUseNativePlayer;
|
||||||
|
|
||||||
// Overlay options
|
// Overlay options
|
||||||
@JsonKey(defaultValue: defaultShowOverlay)
|
@JsonKey(defaultValue: defaultShowOverlay)
|
||||||
|
|
@ -107,7 +106,7 @@ abstract class _SettingsStoreBase with Store {
|
||||||
showVideo = defaultShowVideo;
|
showVideo = defaultShowVideo;
|
||||||
defaultToHighestQuality = defaultDefaultToHighestQuality;
|
defaultToHighestQuality = defaultDefaultToHighestQuality;
|
||||||
useTextureRendering = defaultUseTextureRendering;
|
useTextureRendering = defaultUseTextureRendering;
|
||||||
keepScreenAwake = defaultKeepScreenAwake;
|
useNativePlayer = defaultUseNativePlayer;
|
||||||
|
|
||||||
showOverlay = defaultShowOverlay;
|
showOverlay = defaultShowOverlay;
|
||||||
toggleableOverlay = defaultToggleableOverlay;
|
toggleableOverlay = defaultToggleableOverlay;
|
||||||
|
|
@ -115,44 +114,27 @@ abstract class _SettingsStoreBase with Store {
|
||||||
}
|
}
|
||||||
|
|
||||||
// * Chat Settings
|
// * Chat Settings
|
||||||
// Message sizing defaults
|
|
||||||
|
// -- Messages --
|
||||||
|
// Sizing defaults
|
||||||
static const defaultBadgeScale = 1.0;
|
static const defaultBadgeScale = 1.0;
|
||||||
static const defaultEmoteScale = 1.0;
|
static const defaultEmoteScale = 1.0;
|
||||||
static const defaultMessageScale = 1.0;
|
static const defaultMessageScale = 1.0;
|
||||||
static const defaultMessageSpacing = 8.0;
|
static const defaultMessageSpacing = 8.0;
|
||||||
static const defaultFontSize = 12.0;
|
static const defaultFontSize = 12.0;
|
||||||
|
|
||||||
// Message appearance defaults
|
// Appearance defaults
|
||||||
static const defaultShowDeletedMessages = false;
|
static const defaultShowDeletedMessages = false;
|
||||||
static const defaultShowChatMessageDividers = false;
|
static const defaultShowChatMessageDividers = false;
|
||||||
static const defaultTimestampType = TimestampType.disabled;
|
static const defaultTimestampType = TimestampType.disabled;
|
||||||
|
static const defaultShowHistoricalTimestamps = false;
|
||||||
// Delay defaults
|
static const defaultFocusCurrentChannel = false;
|
||||||
static const defaultAutoSyncChatDelay = false;
|
|
||||||
static const defaultChatDelay = 0.0;
|
|
||||||
|
|
||||||
// Alert defaults
|
// Alert defaults
|
||||||
static const defaultHighlightFirstTimeChatter = true;
|
static const defaultHighlightFirstTimeChatter = true;
|
||||||
static const defaultShowUserNotices = true;
|
static const defaultShowUserNotices = true;
|
||||||
|
|
||||||
// Layout defaults
|
// -- Emotes & Badges --
|
||||||
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 = <String>[];
|
|
||||||
static const defaultMatchWholeWord = true;
|
|
||||||
|
|
||||||
// Autocomplete defaults
|
|
||||||
static const defaultAutocomplete = true;
|
|
||||||
|
|
||||||
// Emotes and badges defaults
|
|
||||||
static const defaultShowTwitchEmotes = true;
|
static const defaultShowTwitchEmotes = true;
|
||||||
static const defaultShowTwitchBadges = true;
|
static const defaultShowTwitchBadges = true;
|
||||||
static const defaultShow7TVEmotes = true;
|
static const defaultShow7TVEmotes = true;
|
||||||
|
|
@ -161,14 +143,26 @@ abstract class _SettingsStoreBase with Store {
|
||||||
static const defaultShowFFZEmotes = true;
|
static const defaultShowFFZEmotes = true;
|
||||||
static const defaultShowFFZBadges = true;
|
static const defaultShowFFZBadges = true;
|
||||||
|
|
||||||
// Recent messages defaults
|
// -- Layout --
|
||||||
static const defaultShowRecentMessages = false;
|
static const defaultEmoteMenuButtonOnLeft = false;
|
||||||
|
|
||||||
// Chat tabs defaults
|
|
||||||
static const defaultPersistChatTabs = true;
|
static const defaultPersistChatTabs = true;
|
||||||
static const defaultSecondaryTabs = <PersistedChatTab>[];
|
static const defaultSecondaryTabs = <PersistedChatTab>[];
|
||||||
|
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 = <String>[];
|
||||||
|
static const defaultMatchWholeWord = true;
|
||||||
|
|
||||||
|
// Messages options
|
||||||
@JsonKey(defaultValue: defaultBadgeScale)
|
@JsonKey(defaultValue: defaultBadgeScale)
|
||||||
@observable
|
@observable
|
||||||
var badgeScale = defaultBadgeScale;
|
var badgeScale = defaultBadgeScale;
|
||||||
|
|
@ -189,7 +183,6 @@ abstract class _SettingsStoreBase with Store {
|
||||||
@observable
|
@observable
|
||||||
var fontSize = defaultFontSize;
|
var fontSize = defaultFontSize;
|
||||||
|
|
||||||
// Message appearance options
|
|
||||||
@JsonKey(defaultValue: defaultShowDeletedMessages)
|
@JsonKey(defaultValue: defaultShowDeletedMessages)
|
||||||
@observable
|
@observable
|
||||||
var showDeletedMessages = defaultShowDeletedMessages;
|
var showDeletedMessages = defaultShowDeletedMessages;
|
||||||
|
|
@ -205,24 +198,14 @@ abstract class _SettingsStoreBase with Store {
|
||||||
@observable
|
@observable
|
||||||
var timestampType = defaultTimestampType;
|
var timestampType = defaultTimestampType;
|
||||||
|
|
||||||
// Delay options
|
@JsonKey(defaultValue: defaultShowHistoricalTimestamps)
|
||||||
@JsonKey(defaultValue: defaultAutoSyncChatDelay)
|
|
||||||
@observable
|
@observable
|
||||||
var autoSyncChatDelay = defaultAutoSyncChatDelay;
|
var showHistoricalTimestamps = defaultShowHistoricalTimestamps;
|
||||||
|
|
||||||
@JsonKey(defaultValue: defaultChatDelay)
|
@JsonKey(defaultValue: defaultFocusCurrentChannel)
|
||||||
@observable
|
@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)
|
@JsonKey(defaultValue: defaultHighlightFirstTimeChatter)
|
||||||
@observable
|
@observable
|
||||||
var highlightFirstTimeChatter = defaultHighlightFirstTimeChatter;
|
var highlightFirstTimeChatter = defaultHighlightFirstTimeChatter;
|
||||||
|
|
@ -231,38 +214,7 @@ abstract class _SettingsStoreBase with Store {
|
||||||
@observable
|
@observable
|
||||||
var showUserNotices = defaultShowUserNotices;
|
var showUserNotices = defaultShowUserNotices;
|
||||||
|
|
||||||
// Layout options
|
// Emotes & Badges options
|
||||||
@JsonKey(defaultValue: defaultEmoteMenuButtonOnLeft)
|
|
||||||
@observable
|
|
||||||
var emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft;
|
|
||||||
|
|
||||||
// Landscape mode options
|
|
||||||
@JsonKey(defaultValue: defaultLandscapeChatLeftSide)
|
|
||||||
@observable
|
|
||||||
var landscapeChatLeftSide = defaultLandscapeChatLeftSide;
|
|
||||||
|
|
||||||
@JsonKey(defaultValue: defaultLandscapeForceVerticalChat)
|
|
||||||
@observable
|
|
||||||
var landscapeForceVerticalChat = defaultLandscapeForceVerticalChat;
|
|
||||||
|
|
||||||
@JsonKey(defaultValue: defaultLandscapeCutout)
|
|
||||||
@observable
|
|
||||||
var landscapeCutout = defaultLandscapeCutout;
|
|
||||||
|
|
||||||
@JsonKey(defaultValue: defaultChatWidth)
|
|
||||||
@observable
|
|
||||||
var chatWidth = defaultChatWidth;
|
|
||||||
|
|
||||||
@JsonKey(defaultValue: defaultFullScreenChatOverlayOpacity)
|
|
||||||
@observable
|
|
||||||
var fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity;
|
|
||||||
|
|
||||||
// Autocomplete options
|
|
||||||
@JsonKey(defaultValue: defaultAutocomplete)
|
|
||||||
@observable
|
|
||||||
var autocomplete = defaultAutocomplete;
|
|
||||||
|
|
||||||
// Emotes and badges
|
|
||||||
@JsonKey(defaultValue: defaultShowTwitchEmotes)
|
@JsonKey(defaultValue: defaultShowTwitchEmotes)
|
||||||
@observable
|
@observable
|
||||||
var showTwitchEmotes = defaultShowTwitchEmotes;
|
var showTwitchEmotes = defaultShowTwitchEmotes;
|
||||||
|
|
@ -291,12 +243,11 @@ abstract class _SettingsStoreBase with Store {
|
||||||
@observable
|
@observable
|
||||||
var showFFZBadges = defaultShowFFZBadges;
|
var showFFZBadges = defaultShowFFZBadges;
|
||||||
|
|
||||||
// Recent messages
|
// Layout options
|
||||||
@JsonKey(defaultValue: defaultShowRecentMessages)
|
@JsonKey(defaultValue: defaultEmoteMenuButtonOnLeft)
|
||||||
@observable
|
@observable
|
||||||
var showRecentMessages = defaultShowRecentMessages;
|
var emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft;
|
||||||
|
|
||||||
// Chat tabs
|
|
||||||
@JsonKey(defaultValue: defaultPersistChatTabs)
|
@JsonKey(defaultValue: defaultPersistChatTabs)
|
||||||
@observable
|
@observable
|
||||||
var persistChatTabs = defaultPersistChatTabs;
|
var persistChatTabs = defaultPersistChatTabs;
|
||||||
|
|
@ -305,6 +256,55 @@ abstract class _SettingsStoreBase with Store {
|
||||||
@observable
|
@observable
|
||||||
var secondaryTabs = defaultSecondaryTabs;
|
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)
|
@JsonKey(defaultValue: defaultMutedWords)
|
||||||
@observable
|
@observable
|
||||||
List<String> mutedWords = defaultMutedWords;
|
List<String> mutedWords = defaultMutedWords;
|
||||||
|
|
@ -315,36 +315,21 @@ abstract class _SettingsStoreBase with Store {
|
||||||
|
|
||||||
@action
|
@action
|
||||||
void resetChatSettings() {
|
void resetChatSettings() {
|
||||||
|
// Messages
|
||||||
badgeScale = defaultBadgeScale;
|
badgeScale = defaultBadgeScale;
|
||||||
emoteScale = defaultEmoteScale;
|
emoteScale = defaultEmoteScale;
|
||||||
messageScale = defaultMessageScale;
|
messageScale = defaultMessageScale;
|
||||||
messageSpacing = defaultMessageSpacing;
|
messageSpacing = defaultMessageSpacing;
|
||||||
fontSize = defaultFontSize;
|
fontSize = defaultFontSize;
|
||||||
|
|
||||||
showDeletedMessages = defaultShowDeletedMessages;
|
showDeletedMessages = defaultShowDeletedMessages;
|
||||||
showChatMessageDividers = defaultShowChatMessageDividers;
|
showChatMessageDividers = defaultShowChatMessageDividers;
|
||||||
timestampType = defaultTimestampType;
|
timestampType = defaultTimestampType;
|
||||||
|
showHistoricalTimestamps = defaultShowHistoricalTimestamps;
|
||||||
autoSyncChatDelay = defaultAutoSyncChatDelay;
|
focusCurrentChannel = defaultFocusCurrentChannel;
|
||||||
chatDelay = defaultChatDelay;
|
|
||||||
syncedChatDelay = defaultChatDelay;
|
|
||||||
|
|
||||||
highlightFirstTimeChatter = defaultHighlightFirstTimeChatter;
|
highlightFirstTimeChatter = defaultHighlightFirstTimeChatter;
|
||||||
showUserNotices = defaultShowUserNotices;
|
showUserNotices = defaultShowUserNotices;
|
||||||
|
|
||||||
emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft;
|
// Emotes & Badges
|
||||||
|
|
||||||
landscapeChatLeftSide = defaultLandscapeChatLeftSide;
|
|
||||||
landscapeForceVerticalChat = defaultLandscapeForceVerticalChat;
|
|
||||||
landscapeCutout = defaultLandscapeCutout;
|
|
||||||
chatWidth = defaultChatWidth;
|
|
||||||
fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity;
|
|
||||||
|
|
||||||
mutedWords = defaultMutedWords;
|
|
||||||
matchWholeWord = defaultMatchWholeWord;
|
|
||||||
|
|
||||||
autocomplete = defaultAutocomplete;
|
|
||||||
|
|
||||||
showTwitchEmotes = defaultShowTwitchEmotes;
|
showTwitchEmotes = defaultShowTwitchEmotes;
|
||||||
showTwitchBadges = defaultShowTwitchBadges;
|
showTwitchBadges = defaultShowTwitchBadges;
|
||||||
show7TVEmotes = defaultShow7TVEmotes;
|
show7TVEmotes = defaultShow7TVEmotes;
|
||||||
|
|
@ -353,10 +338,25 @@ abstract class _SettingsStoreBase with Store {
|
||||||
showFFZEmotes = defaultShowFFZEmotes;
|
showFFZEmotes = defaultShowFFZEmotes;
|
||||||
showFFZBadges = defaultShowFFZBadges;
|
showFFZBadges = defaultShowFFZBadges;
|
||||||
|
|
||||||
showRecentMessages = defaultShowRecentMessages;
|
// Layout
|
||||||
|
emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft;
|
||||||
persistChatTabs = defaultPersistChatTabs;
|
persistChatTabs = defaultPersistChatTabs;
|
||||||
secondaryTabs = defaultSecondaryTabs;
|
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
|
// * Other settings
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ SettingsStore _$SettingsStoreFromJson(
|
||||||
..showVideo = json['showVideo'] as bool? ?? true
|
..showVideo = json['showVideo'] as bool? ?? true
|
||||||
..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false
|
..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false
|
||||||
..useTextureRendering = json['useTextureRendering'] as bool? ?? true
|
..useTextureRendering = json['useTextureRendering'] as bool? ?? true
|
||||||
..keepScreenAwake = json['keepScreenAwake'] as bool? ?? true
|
..useNativePlayer = json['useNativePlayer'] as bool? ?? false
|
||||||
..showOverlay = json['showOverlay'] as bool? ?? true
|
..showOverlay = json['showOverlay'] as bool? ?? true
|
||||||
..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false
|
..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false
|
||||||
..showLatency = json['showLatency'] as bool? ?? false
|
..showLatency = json['showLatency'] as bool? ?? false
|
||||||
|
|
@ -41,12 +41,26 @@ SettingsStore _$SettingsStoreFromJson(
|
||||||
unknownValue: TimestampType.disabled,
|
unknownValue: TimestampType.disabled,
|
||||||
) ??
|
) ??
|
||||||
TimestampType.disabled
|
TimestampType.disabled
|
||||||
..autoSyncChatDelay = json['autoSyncChatDelay'] as bool? ?? false
|
..showHistoricalTimestamps =
|
||||||
..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0
|
json['showHistoricalTimestamps'] as bool? ?? false
|
||||||
|
..focusCurrentChannel = json['focusCurrentChannel'] as bool? ?? false
|
||||||
..highlightFirstTimeChatter =
|
..highlightFirstTimeChatter =
|
||||||
json['highlightFirstTimeChatter'] as bool? ?? true
|
json['highlightFirstTimeChatter'] as bool? ?? true
|
||||||
..showUserNotices = json['showUserNotices'] 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
|
..emoteMenuButtonOnLeft = json['emoteMenuButtonOnLeft'] as bool? ?? false
|
||||||
|
..persistChatTabs = json['persistChatTabs'] as bool? ?? true
|
||||||
|
..secondaryTabs =
|
||||||
|
(json['secondaryTabs'] as List<dynamic>?)
|
||||||
|
?.map((e) => PersistedChatTab.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
[]
|
||||||
..landscapeChatLeftSide = json['landscapeChatLeftSide'] as bool? ?? false
|
..landscapeChatLeftSide = json['landscapeChatLeftSide'] as bool? ?? false
|
||||||
..landscapeForceVerticalChat =
|
..landscapeForceVerticalChat =
|
||||||
json['landscapeForceVerticalChat'] as bool? ?? false
|
json['landscapeForceVerticalChat'] as bool? ?? false
|
||||||
|
|
@ -59,21 +73,11 @@ SettingsStore _$SettingsStoreFromJson(
|
||||||
..chatWidth = (json['chatWidth'] as num?)?.toDouble() ?? 0.2
|
..chatWidth = (json['chatWidth'] as num?)?.toDouble() ?? 0.2
|
||||||
..fullScreenChatOverlayOpacity =
|
..fullScreenChatOverlayOpacity =
|
||||||
(json['fullScreenChatOverlayOpacity'] as num?)?.toDouble() ?? 0.5
|
(json['fullScreenChatOverlayOpacity'] as num?)?.toDouble() ?? 0.5
|
||||||
|
..keepScreenAwake = json['keepScreenAwake'] as bool? ?? true
|
||||||
..autocomplete = json['autocomplete'] 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
|
..showRecentMessages = json['showRecentMessages'] as bool? ?? false
|
||||||
..persistChatTabs = json['persistChatTabs'] as bool? ?? true
|
..autoSyncChatDelay = json['autoSyncChatDelay'] as bool? ?? false
|
||||||
..secondaryTabs =
|
..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0
|
||||||
(json['secondaryTabs'] as List<dynamic>?)
|
|
||||||
?.map((e) => PersistedChatTab.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList() ??
|
|
||||||
[]
|
|
||||||
..mutedWords =
|
..mutedWords =
|
||||||
(json['mutedWords'] as List<dynamic>?)
|
(json['mutedWords'] as List<dynamic>?)
|
||||||
?.map((e) => e as String)
|
?.map((e) => e as String)
|
||||||
|
|
@ -101,7 +105,7 @@ Map<String, dynamic> _$SettingsStoreToJson(
|
||||||
'showVideo': instance.showVideo,
|
'showVideo': instance.showVideo,
|
||||||
'defaultToHighestQuality': instance.defaultToHighestQuality,
|
'defaultToHighestQuality': instance.defaultToHighestQuality,
|
||||||
'useTextureRendering': instance.useTextureRendering,
|
'useTextureRendering': instance.useTextureRendering,
|
||||||
'keepScreenAwake': instance.keepScreenAwake,
|
'useNativePlayer': instance.useNativePlayer,
|
||||||
'showOverlay': instance.showOverlay,
|
'showOverlay': instance.showOverlay,
|
||||||
'toggleableOverlay': instance.toggleableOverlay,
|
'toggleableOverlay': instance.toggleableOverlay,
|
||||||
'showLatency': instance.showLatency,
|
'showLatency': instance.showLatency,
|
||||||
|
|
@ -113,17 +117,10 @@ Map<String, dynamic> _$SettingsStoreToJson(
|
||||||
'showDeletedMessages': instance.showDeletedMessages,
|
'showDeletedMessages': instance.showDeletedMessages,
|
||||||
'showChatMessageDividers': instance.showChatMessageDividers,
|
'showChatMessageDividers': instance.showChatMessageDividers,
|
||||||
'timestampType': _$TimestampTypeEnumMap[instance.timestampType]!,
|
'timestampType': _$TimestampTypeEnumMap[instance.timestampType]!,
|
||||||
'autoSyncChatDelay': instance.autoSyncChatDelay,
|
'showHistoricalTimestamps': instance.showHistoricalTimestamps,
|
||||||
'chatDelay': instance.chatDelay,
|
'focusCurrentChannel': instance.focusCurrentChannel,
|
||||||
'highlightFirstTimeChatter': instance.highlightFirstTimeChatter,
|
'highlightFirstTimeChatter': instance.highlightFirstTimeChatter,
|
||||||
'showUserNotices': instance.showUserNotices,
|
'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,
|
'showTwitchEmotes': instance.showTwitchEmotes,
|
||||||
'showTwitchBadges': instance.showTwitchBadges,
|
'showTwitchBadges': instance.showTwitchBadges,
|
||||||
'show7TVEmotes': instance.show7TVEmotes,
|
'show7TVEmotes': instance.show7TVEmotes,
|
||||||
|
|
@ -131,9 +128,19 @@ Map<String, dynamic> _$SettingsStoreToJson(
|
||||||
'showBTTVBadges': instance.showBTTVBadges,
|
'showBTTVBadges': instance.showBTTVBadges,
|
||||||
'showFFZEmotes': instance.showFFZEmotes,
|
'showFFZEmotes': instance.showFFZEmotes,
|
||||||
'showFFZBadges': instance.showFFZBadges,
|
'showFFZBadges': instance.showFFZBadges,
|
||||||
'showRecentMessages': instance.showRecentMessages,
|
'emoteMenuButtonOnLeft': instance.emoteMenuButtonOnLeft,
|
||||||
'persistChatTabs': instance.persistChatTabs,
|
'persistChatTabs': instance.persistChatTabs,
|
||||||
'secondaryTabs': instance.secondaryTabs,
|
'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,
|
'mutedWords': instance.mutedWords,
|
||||||
'matchWholeWord': instance.matchWholeWord,
|
'matchWholeWord': instance.matchWholeWord,
|
||||||
'shareCrashLogsAndAnalytics': instance.shareCrashLogsAndAnalytics,
|
'shareCrashLogsAndAnalytics': instance.shareCrashLogsAndAnalytics,
|
||||||
|
|
@ -325,21 +332,21 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
late final _$keepScreenAwakeAtom = Atom(
|
late final _$useNativePlayerAtom = Atom(
|
||||||
name: '_SettingsStoreBase.keepScreenAwake',
|
name: '_SettingsStoreBase.useNativePlayer',
|
||||||
context: context,
|
context: context,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get keepScreenAwake {
|
bool get useNativePlayer {
|
||||||
_$keepScreenAwakeAtom.reportRead();
|
_$useNativePlayerAtom.reportRead();
|
||||||
return super.keepScreenAwake;
|
return super.useNativePlayer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
set keepScreenAwake(bool value) {
|
set useNativePlayer(bool value) {
|
||||||
_$keepScreenAwakeAtom.reportWrite(value, super.keepScreenAwake, () {
|
_$useNativePlayerAtom.reportWrite(value, super.useNativePlayer, () {
|
||||||
super.keepScreenAwake = value;
|
super.useNativePlayer = value;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -545,57 +552,43 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
late final _$autoSyncChatDelayAtom = Atom(
|
late final _$showHistoricalTimestampsAtom = Atom(
|
||||||
name: '_SettingsStoreBase.autoSyncChatDelay',
|
name: '_SettingsStoreBase.showHistoricalTimestamps',
|
||||||
context: context,
|
context: context,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get autoSyncChatDelay {
|
bool get showHistoricalTimestamps {
|
||||||
_$autoSyncChatDelayAtom.reportRead();
|
_$showHistoricalTimestampsAtom.reportRead();
|
||||||
return super.autoSyncChatDelay;
|
return super.showHistoricalTimestamps;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
set autoSyncChatDelay(bool value) {
|
set showHistoricalTimestamps(bool value) {
|
||||||
_$autoSyncChatDelayAtom.reportWrite(value, super.autoSyncChatDelay, () {
|
_$showHistoricalTimestampsAtom.reportWrite(
|
||||||
super.autoSyncChatDelay = value;
|
value,
|
||||||
});
|
super.showHistoricalTimestamps,
|
||||||
|
() {
|
||||||
|
super.showHistoricalTimestamps = value;
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
late final _$chatDelayAtom = Atom(
|
late final _$focusCurrentChannelAtom = Atom(
|
||||||
name: '_SettingsStoreBase.chatDelay',
|
name: '_SettingsStoreBase.focusCurrentChannel',
|
||||||
context: context,
|
context: context,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
double get chatDelay {
|
bool get focusCurrentChannel {
|
||||||
_$chatDelayAtom.reportRead();
|
_$focusCurrentChannelAtom.reportRead();
|
||||||
return super.chatDelay;
|
return super.focusCurrentChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
set chatDelay(double value) {
|
set focusCurrentChannel(bool value) {
|
||||||
_$chatDelayAtom.reportWrite(value, super.chatDelay, () {
|
_$focusCurrentChannelAtom.reportWrite(value, super.focusCurrentChannel, () {
|
||||||
super.chatDelay = value;
|
super.focusCurrentChannel = 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;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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(
|
late final _$showTwitchEmotesAtom = Atom(
|
||||||
name: '_SettingsStoreBase.showTwitchEmotes',
|
name: '_SettingsStoreBase.showTwitchEmotes',
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -907,22 +758,26 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
late final _$showRecentMessagesAtom = Atom(
|
late final _$emoteMenuButtonOnLeftAtom = Atom(
|
||||||
name: '_SettingsStoreBase.showRecentMessages',
|
name: '_SettingsStoreBase.emoteMenuButtonOnLeft',
|
||||||
context: context,
|
context: context,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get showRecentMessages {
|
bool get emoteMenuButtonOnLeft {
|
||||||
_$showRecentMessagesAtom.reportRead();
|
_$emoteMenuButtonOnLeftAtom.reportRead();
|
||||||
return super.showRecentMessages;
|
return super.emoteMenuButtonOnLeft;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
set showRecentMessages(bool value) {
|
set emoteMenuButtonOnLeft(bool value) {
|
||||||
_$showRecentMessagesAtom.reportWrite(value, super.showRecentMessages, () {
|
_$emoteMenuButtonOnLeftAtom.reportWrite(
|
||||||
super.showRecentMessages = value;
|
value,
|
||||||
});
|
super.emoteMenuButtonOnLeft,
|
||||||
|
() {
|
||||||
|
super.emoteMenuButtonOnLeft = value;
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
late final _$persistChatTabsAtom = Atom(
|
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(
|
late final _$mutedWordsAtom = Atom(
|
||||||
name: '_SettingsStoreBase.mutedWords',
|
name: '_SettingsStoreBase.mutedWords',
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -1165,7 +1230,7 @@ launchUrlExternal: ${launchUrlExternal},
|
||||||
showVideo: ${showVideo},
|
showVideo: ${showVideo},
|
||||||
defaultToHighestQuality: ${defaultToHighestQuality},
|
defaultToHighestQuality: ${defaultToHighestQuality},
|
||||||
useTextureRendering: ${useTextureRendering},
|
useTextureRendering: ${useTextureRendering},
|
||||||
keepScreenAwake: ${keepScreenAwake},
|
useNativePlayer: ${useNativePlayer},
|
||||||
showOverlay: ${showOverlay},
|
showOverlay: ${showOverlay},
|
||||||
toggleableOverlay: ${toggleableOverlay},
|
toggleableOverlay: ${toggleableOverlay},
|
||||||
showLatency: ${showLatency},
|
showLatency: ${showLatency},
|
||||||
|
|
@ -1177,18 +1242,10 @@ fontSize: ${fontSize},
|
||||||
showDeletedMessages: ${showDeletedMessages},
|
showDeletedMessages: ${showDeletedMessages},
|
||||||
showChatMessageDividers: ${showChatMessageDividers},
|
showChatMessageDividers: ${showChatMessageDividers},
|
||||||
timestampType: ${timestampType},
|
timestampType: ${timestampType},
|
||||||
autoSyncChatDelay: ${autoSyncChatDelay},
|
showHistoricalTimestamps: ${showHistoricalTimestamps},
|
||||||
chatDelay: ${chatDelay},
|
focusCurrentChannel: ${focusCurrentChannel},
|
||||||
syncedChatDelay: ${syncedChatDelay},
|
|
||||||
highlightFirstTimeChatter: ${highlightFirstTimeChatter},
|
highlightFirstTimeChatter: ${highlightFirstTimeChatter},
|
||||||
showUserNotices: ${showUserNotices},
|
showUserNotices: ${showUserNotices},
|
||||||
emoteMenuButtonOnLeft: ${emoteMenuButtonOnLeft},
|
|
||||||
landscapeChatLeftSide: ${landscapeChatLeftSide},
|
|
||||||
landscapeForceVerticalChat: ${landscapeForceVerticalChat},
|
|
||||||
landscapeCutout: ${landscapeCutout},
|
|
||||||
chatWidth: ${chatWidth},
|
|
||||||
fullScreenChatOverlayOpacity: ${fullScreenChatOverlayOpacity},
|
|
||||||
autocomplete: ${autocomplete},
|
|
||||||
showTwitchEmotes: ${showTwitchEmotes},
|
showTwitchEmotes: ${showTwitchEmotes},
|
||||||
showTwitchBadges: ${showTwitchBadges},
|
showTwitchBadges: ${showTwitchBadges},
|
||||||
show7TVEmotes: ${show7TVEmotes},
|
show7TVEmotes: ${show7TVEmotes},
|
||||||
|
|
@ -1196,9 +1253,20 @@ showBTTVEmotes: ${showBTTVEmotes},
|
||||||
showBTTVBadges: ${showBTTVBadges},
|
showBTTVBadges: ${showBTTVBadges},
|
||||||
showFFZEmotes: ${showFFZEmotes},
|
showFFZEmotes: ${showFFZEmotes},
|
||||||
showFFZBadges: ${showFFZBadges},
|
showFFZBadges: ${showFFZBadges},
|
||||||
showRecentMessages: ${showRecentMessages},
|
emoteMenuButtonOnLeft: ${emoteMenuButtonOnLeft},
|
||||||
persistChatTabs: ${persistChatTabs},
|
persistChatTabs: ${persistChatTabs},
|
||||||
secondaryTabs: ${secondaryTabs},
|
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},
|
mutedWords: ${mutedWords},
|
||||||
matchWholeWord: ${matchWholeWord},
|
matchWholeWord: ${matchWholeWord},
|
||||||
shareCrashLogsAndAnalytics: ${shareCrashLogsAndAnalytics},
|
shareCrashLogsAndAnalytics: ${shareCrashLogsAndAnalytics},
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,20 @@ class VideoSettings extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
const SectionHeader('Player', isFirst: true),
|
const SectionHeader('Player', isFirst: true),
|
||||||
SettingsListSwitch(
|
SettingsListSwitch(
|
||||||
title: 'Enable video',
|
title: 'Show video player',
|
||||||
value: settingsStore.showVideo,
|
value: settingsStore.showVideo,
|
||||||
onChanged: (newValue) => settingsStore.showVideo = newValue,
|
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())
|
if (!Platform.isIOS || isIPad())
|
||||||
SettingsListSwitch(
|
SettingsListSwitch(
|
||||||
title: 'Default to highest quality',
|
title: 'Default to highest quality',
|
||||||
|
|
@ -41,14 +51,6 @@ class VideoSettings extends StatelessWidget {
|
||||||
onChanged: (newValue) =>
|
onChanged: (newValue) =>
|
||||||
settingsStore.useTextureRendering = 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'),
|
const SectionHeader('Overlay'),
|
||||||
SettingsListSwitch(
|
SettingsListSwitch(
|
||||||
title: 'Use custom video overlay',
|
title: 'Use custom video overlay',
|
||||||
|
|
@ -59,12 +61,13 @@ class VideoSettings extends StatelessWidget {
|
||||||
onChanged: (newValue) => settingsStore.showOverlay = newValue,
|
onChanged: (newValue) => settingsStore.showOverlay = newValue,
|
||||||
),
|
),
|
||||||
SettingsListSwitch(
|
SettingsListSwitch(
|
||||||
title: 'Long-press player to toggle overlay',
|
title: 'Toggle overlay on long-press',
|
||||||
subtitle: const Text(
|
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,
|
value: settingsStore.toggleableOverlay,
|
||||||
onChanged: (newValue) => settingsStore.toggleableOverlay = newValue,
|
onChanged: (newValue) =>
|
||||||
|
settingsStore.toggleableOverlay = newValue,
|
||||||
),
|
),
|
||||||
SettingsListSwitch(
|
SettingsListSwitch(
|
||||||
title: 'Show latency',
|
title: 'Show latency',
|
||||||
|
|
|
||||||
9
lib/services/cookie_extractor.dart
Normal file
9
lib/services/cookie_extractor.dart
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
class CookieExtractor {
|
||||||
|
static const _channel = MethodChannel('frosty/cookie_extractor');
|
||||||
|
|
||||||
|
static Future<String?> extractTwitchAuthToken() async {
|
||||||
|
return await _channel.invokeMethod<String>('extractTwitchAuthToken');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -39,23 +39,23 @@ class FrostyColors extends ThemeExtension<FrostyColors> {
|
||||||
required this.overlayOnSurface,
|
required this.overlayOnSurface,
|
||||||
});
|
});
|
||||||
|
|
||||||
static const light = FrostyColors(
|
static FrostyColors light({required ColorScheme colorScheme}) => FrostyColors(
|
||||||
highlightedMessage: Color(0xff9146ff),
|
highlightedMessage: colorScheme.primary,
|
||||||
emoteOnly: Color(0xFFFFB74D),
|
emoteOnly: const Color(0xFFFFB74D),
|
||||||
followersOnly: Color(0xFFF44336),
|
followersOnly: const Color(0xFFF44336),
|
||||||
slowMode: Color(0xFF2196F3),
|
slowMode: const Color(0xFF2196F3),
|
||||||
subOnly: Color(0xFF4CAF50),
|
subOnly: const Color(0xFF4CAF50),
|
||||||
uniqueMode: Color(0xFFAB47BC),
|
uniqueMode: const Color(0xFFAB47BC),
|
||||||
overlayOnSurface: Colors.white,
|
overlayOnSurface: Colors.white,
|
||||||
);
|
);
|
||||||
|
|
||||||
static const dark = FrostyColors(
|
static FrostyColors dark({required ColorScheme colorScheme}) => FrostyColors(
|
||||||
highlightedMessage: Color(0xff9146ff),
|
highlightedMessage: colorScheme.primary,
|
||||||
emoteOnly: Color(0xFFFFCC80),
|
emoteOnly: const Color(0xFFFFCC80),
|
||||||
followersOnly: Color(0xFFE57373),
|
followersOnly: const Color(0xFFE57373),
|
||||||
slowMode: Color(0xFF64B5F6),
|
slowMode: const Color(0xFF64B5F6),
|
||||||
subOnly: Color(0xFF81C784),
|
subOnly: const Color(0xFF81C784),
|
||||||
uniqueMode: Color(0xFFBA68C8),
|
uniqueMode: const Color(0xFFBA68C8),
|
||||||
overlayOnSurface: Colors.white,
|
overlayOnSurface: Colors.white,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -124,12 +124,17 @@ class FrostyThemes {
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
extensions: [
|
extensions: [
|
||||||
colorScheme.brightness == Brightness.dark
|
colorScheme.brightness == Brightness.dark
|
||||||
? FrostyColors.dark
|
? FrostyColors.dark(colorScheme: colorScheme)
|
||||||
: FrostyColors.light,
|
: FrostyColors.light(colorScheme: colorScheme),
|
||||||
],
|
],
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
fontFamily: 'Inter',
|
fontFamily: 'Inter',
|
||||||
splashFactory: Platform.isIOS ? NoSplash.splashFactory : null,
|
splashFactory: Platform.isIOS ? NoSplash.splashFactory : null,
|
||||||
|
textSelectionTheme: TextSelectionThemeData(
|
||||||
|
cursorColor: colorScheme.primary,
|
||||||
|
selectionColor: colorScheme.primary.withValues(alpha: 0.3),
|
||||||
|
selectionHandleColor: colorScheme.primary,
|
||||||
|
),
|
||||||
scaffoldBackgroundColor: backgroundColor,
|
scaffoldBackgroundColor: backgroundColor,
|
||||||
bottomSheetTheme: BottomSheetThemeData(
|
bottomSheetTheme: BottomSheetThemeData(
|
||||||
showDragHandle: true,
|
showDragHandle: true,
|
||||||
|
|
@ -139,7 +144,7 @@ class FrostyThemes {
|
||||||
menuTheme: MenuThemeData(
|
menuTheme: MenuThemeData(
|
||||||
style: MenuStyle(
|
style: MenuStyle(
|
||||||
padding: const WidgetStatePropertyAll(
|
padding: const WidgetStatePropertyAll(
|
||||||
EdgeInsets.symmetric(vertical: 4),
|
EdgeInsets.symmetric(vertical: 2),
|
||||||
),
|
),
|
||||||
shape: WidgetStatePropertyAll(
|
shape: WidgetStatePropertyAll(
|
||||||
RoundedRectangleBorder(
|
RoundedRectangleBorder(
|
||||||
|
|
@ -152,6 +157,9 @@ class FrostyThemes {
|
||||||
menuButtonTheme: MenuButtonThemeData(
|
menuButtonTheme: MenuButtonThemeData(
|
||||||
style: MenuItemButton.styleFrom(
|
style: MenuItemButton.styleFrom(
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
|
minimumSize: const Size(0, 40),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
dialogTheme: DialogThemeData(
|
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(
|
navigationBarTheme: NavigationBarThemeData(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
backgroundColor: backgroundColor,
|
backgroundColor: backgroundColor,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:frosty/apis/bttv_api.dart';
|
||||||
import 'package:frosty/apis/ffz_api.dart';
|
import 'package:frosty/apis/ffz_api.dart';
|
||||||
import 'package:frosty/apis/seventv_api.dart';
|
import 'package:frosty/apis/seventv_api.dart';
|
||||||
import 'package:frosty/apis/twitch_api.dart';
|
import 'package:frosty/apis/twitch_api.dart';
|
||||||
|
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||||
import 'package:frosty/stores/global_assets_store.dart';
|
import 'package:frosty/stores/global_assets_store.dart';
|
||||||
|
|
@ -13,17 +14,17 @@ import 'package:provider/provider.dart';
|
||||||
class OrientationUtils {
|
class OrientationUtils {
|
||||||
/// Gets the current orientation from the given context
|
/// Gets the current orientation from the given context
|
||||||
static Orientation getCurrentOrientation(BuildContext context) {
|
static Orientation getCurrentOrientation(BuildContext context) {
|
||||||
return MediaQuery.of(context).orientation;
|
return MediaQuery.orientationOf(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if the current orientation is portrait
|
/// Returns true if the current orientation is portrait
|
||||||
static bool isPortrait(BuildContext context) {
|
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
|
/// Returns true if the current orientation is landscape
|
||||||
static bool isLandscape(BuildContext context) {
|
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 =====
|
// ===== SCREEN & LAYOUT =====
|
||||||
|
|
||||||
/// Gets the screen width
|
/// Gets the screen width
|
||||||
double get screenWidth => MediaQuery.of(this).size.width;
|
double get screenWidth => MediaQuery.sizeOf(this).width;
|
||||||
|
|
||||||
/// Gets the screen height
|
/// 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)
|
/// 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)
|
/// 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 =====
|
// ===== PROVIDER ACCESS =====
|
||||||
|
|
||||||
/// Gets TwitchApi from provider
|
/// Gets TwitchApi from provider
|
||||||
TwitchApi get twitchApi => read<TwitchApi>();
|
TwitchApi get twitchApi => read<TwitchApi>();
|
||||||
|
|
||||||
|
/// Gets TwitchGqlApi from provider
|
||||||
|
TwitchGqlApi get twitchGqlApi => read<TwitchGqlApi>();
|
||||||
|
|
||||||
/// Gets AuthStore from provider
|
/// Gets AuthStore from provider
|
||||||
AuthStore get authStore => read<AuthStore>();
|
AuthStore get authStore => read<AuthStore>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,10 +49,10 @@ class ExpandableSectionHeader extends StatelessWidget {
|
||||||
child: Text(
|
child: Text(
|
||||||
text.toUpperCase(),
|
text.toUpperCase(),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: fontSize ?? 13,
|
fontSize: fontSize ?? 12,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w500,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.8,
|
||||||
color: context.colorScheme.onSurface.withValues(
|
color: context.colorScheme.onSurfaceVariant.withValues(
|
||||||
alpha: 0.6,
|
alpha: 0.6,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -63,7 +63,9 @@ class ExpandableSectionHeader extends StatelessWidget {
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.keyboard_arrow_down,
|
Icons.keyboard_arrow_down,
|
||||||
color: context.colorScheme.onSurfaceVariant,
|
color: context.colorScheme.onSurfaceVariant.withValues(
|
||||||
|
alpha: 0.6,
|
||||||
|
),
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -138,57 +138,61 @@ class _LiveIndicatorState extends State<LiveIndicator>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return AnimatedBuilder(
|
return SizedBox(
|
||||||
animation: _pingController!,
|
width: widget.size,
|
||||||
builder: (context, child) {
|
height: widget.size,
|
||||||
final pingSize = widget.size * _pingScale!.value;
|
child: Stack(
|
||||||
return SizedBox(
|
alignment: Alignment.center,
|
||||||
width: widget.size,
|
clipBehavior: Clip.none,
|
||||||
height: widget.size,
|
children: [
|
||||||
child: Stack(
|
// Ping ring in its own RepaintBoundary so its 60fps animation
|
||||||
alignment: Alignment.center,
|
// doesn't dirty the overlay's compositing layer (expensive with
|
||||||
clipBehavior: Clip.none,
|
// Impeller + PlatformView).
|
||||||
children: [
|
Positioned.fill(
|
||||||
// Ping ring rendered outside without affecting layout
|
child: RepaintBoundary(
|
||||||
Positioned.fill(
|
child: AnimatedBuilder(
|
||||||
child: IgnorePointer(
|
animation: _pingController!,
|
||||||
child: OverflowBox(
|
builder: (context, child) {
|
||||||
maxWidth: pingSize,
|
final pingSize = widget.size * _pingScale!.value;
|
||||||
maxHeight: pingSize,
|
return IgnorePointer(
|
||||||
child: Center(
|
child: OverflowBox(
|
||||||
child: Opacity(
|
maxWidth: pingSize,
|
||||||
opacity: _pingOpacity!.value,
|
maxHeight: pingSize,
|
||||||
child: Container(
|
child: Center(
|
||||||
width: pingSize,
|
child: Opacity(
|
||||||
height: pingSize,
|
opacity: _pingOpacity!.value,
|
||||||
decoration: BoxDecoration(
|
child: Container(
|
||||||
shape: BoxShape.circle,
|
width: pingSize,
|
||||||
border: Border.all(
|
height: pingSize,
|
||||||
color: widget.color.withValues(
|
decoration: BoxDecoration(
|
||||||
alpha: _borderOpacity,
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
46
lib/widgets/measure_size.dart
Normal file
46
lib/widgets/measure_size.dart
Normal file
|
|
@ -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<Size> 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<Size> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,11 @@ class ProfilePicture extends StatefulWidget {
|
||||||
final String userLogin;
|
final String userLogin;
|
||||||
final double radius;
|
final double radius;
|
||||||
|
|
||||||
const ProfilePicture({super.key, required this.userLogin, this.radius = 20});
|
const ProfilePicture({
|
||||||
|
super.key,
|
||||||
|
required this.userLogin,
|
||||||
|
this.radius = 20,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ProfilePicture> createState() => _ProfilePictureState();
|
State<ProfilePicture> createState() => _ProfilePictureState();
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,12 @@ class SectionHeader extends StatelessWidget {
|
||||||
Text(
|
Text(
|
||||||
text.toUpperCase(),
|
text.toUpperCase(),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: fontSize ?? 13,
|
fontSize: fontSize ?? 12,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w500,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.8,
|
||||||
color: context.colorScheme.onSurfaceVariant,
|
color: context.colorScheme.onSurfaceVariant.withValues(
|
||||||
|
alpha: 0.6,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ class _SettingsPageLayoutState extends State<SettingsPageLayout> {
|
||||||
|
|
||||||
// Responsive values based on orientation
|
// Responsive values based on orientation
|
||||||
final listPadding = EdgeInsets.only(
|
final listPadding = EdgeInsets.only(
|
||||||
top: isPortrait ? 116 : 92,
|
top: isPortrait ? 124 : 100,
|
||||||
bottom: widget.hasBottomPadding ? context.safePaddingBottom + 8 : 0,
|
bottom: widget.hasBottomPadding ? context.safePaddingBottom + 8 : 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
3033
pubspec.lock
3033
pubspec.lock
File diff suppressed because it is too large
Load diff
|
|
@ -76,6 +76,10 @@ dependencies:
|
||||||
url: https://github.com/tommyxchow/extended_text_field.git
|
url: https://github.com/tommyxchow/extended_text_field.git
|
||||||
ref: 76305e9b115f0b41dc0d23e7a4f651df454fea89
|
ref: 76305e9b115f0b41dc0d23e7a4f651df454fea89
|
||||||
native_device_orientation: ^2.1.0
|
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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
78
test/models/playback_access_token_test.dart
Normal file
78
test/models/playback_access_token_test.dart
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:frosty/models/playback_access_token.dart';
|
||||||
|
|
||||||
|
Map<String, dynamic> 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
111
test/screens/channel/video/chat_latency_sync_test.dart
Normal file
111
test/screens/channel/video/chat_latency_sync_test.dart
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
194
test/screens/channel/video/stream_info_poller_test.dart
Normal file
194
test/screens/channel/video/stream_info_poller_test.dart
Normal file
|
|
@ -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<String, dynamic>,
|
||||||
|
);
|
||||||
|
|
||||||
|
Channel buildChannel() => Channel.fromJson(
|
||||||
|
(twitchChannelResponse['data']! as List).first as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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<StreamTwitch>();
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
159
test/screens/channel/video/video_timing_constants_test.dart
Normal file
159
test/screens/channel/video/video_timing_constants_test.dart
Normal file
|
|
@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
1
web/.gitignore
vendored
1
web/.gitignore
vendored
|
|
@ -45,3 +45,4 @@ next-env.d.ts
|
||||||
|
|
||||||
# ai
|
# ai
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
.playwright-mcp
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,19 @@
|
||||||
# AGENTS.md
|
# 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
|
## Commands
|
||||||
|
|
||||||
```bash
|
```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
|
pnpm nuke # Delete .next, .open-next, node_modules, and pnpm-lock.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Source Structure
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
- `src/app/` - App Router pages and layouts
|
- `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 <component>`)
|
- `src/components/` - React components (`ui/` subdirectory for shadcn `base-vega` style — add with `pnpm dlx shadcn@latest add <component>`)
|
||||||
- `src/lib/` - Utilities (`cn()` for className merging, `constants.ts` for external links)
|
- `src/lib/` - Utilities (`cn()` for className merging, `constants.ts` for external links)
|
||||||
- `src/assets/` - Static images (screenshots)
|
- `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 `<Suspense>` 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.)
|
- `wrangler.jsonc` - Cloudflare Workers configuration (bindings, R2, KV, etc.)
|
||||||
- `open-next.config.ts` - OpenNext adapter config (ISR requires uncommenting R2 incremental cache)
|
- `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
|
- `react-you-might-not-need-an-effect` - Avoid unnecessary useEffect
|
||||||
- Unused variables must be prefixed with `_`
|
- 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 `<Suspense>` 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
|
## Dev Tooling
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"@opennextjs/cloudflare": "^1.16.5",
|
"@opennextjs/cloudflare": "^1.16.5",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|
@ -50,7 +49,6 @@
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"prettier-plugin-organize-imports": "^4.3.0",
|
"prettier-plugin-organize-imports": "^4.3.0",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"react-scan": "^0.5.3",
|
|
||||||
"shadcn": "^3.8.5",
|
"shadcn": "^3.8.5",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
|
|
||||||
420
web/pnpm-lock.yaml
generated
420
web/pnpm-lock.yaml
generated
|
|
@ -60,9 +60,6 @@ importers:
|
||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4.1.18
|
specifier: ^4.1.18
|
||||||
version: 4.1.18
|
version: 4.1.18
|
||||||
'@tailwindcss/typography':
|
|
||||||
specifier: ^0.5.19
|
|
||||||
version: 0.5.19(tailwindcss@4.1.18)
|
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^25.2.3
|
specifier: ^25.2.3
|
||||||
version: 25.2.3
|
version: 25.2.3
|
||||||
|
|
@ -102,9 +99,6 @@ importers:
|
||||||
prettier-plugin-tailwindcss:
|
prettier-plugin-tailwindcss:
|
||||||
specifier: ^0.7.2
|
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)
|
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:
|
shadcn:
|
||||||
specifier: ^3.8.5
|
specifier: ^3.8.5
|
||||||
version: 3.8.5(@types/node@25.2.3)(typescript@5.9.3)
|
version: 3.8.5(@types/node@25.2.3)(typescript@5.9.3)
|
||||||
|
|
@ -612,12 +606,6 @@ packages:
|
||||||
'@emnapi/wasi-threads@1.1.0':
|
'@emnapi/wasi-threads@1.1.0':
|
||||||
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
|
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':
|
'@esbuild/aix-ppc64@0.25.4':
|
||||||
resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==}
|
resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -630,12 +618,6 @@ packages:
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [aix]
|
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':
|
'@esbuild/android-arm64@0.25.4':
|
||||||
resolution: {integrity: sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==}
|
resolution: {integrity: sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -648,12 +630,6 @@ packages:
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [android]
|
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':
|
'@esbuild/android-arm@0.25.4':
|
||||||
resolution: {integrity: sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==}
|
resolution: {integrity: sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -666,12 +642,6 @@ packages:
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [android]
|
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':
|
'@esbuild/android-x64@0.25.4':
|
||||||
resolution: {integrity: sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==}
|
resolution: {integrity: sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -684,12 +654,6 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [android]
|
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':
|
'@esbuild/darwin-arm64@0.25.4':
|
||||||
resolution: {integrity: sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==}
|
resolution: {integrity: sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -702,12 +666,6 @@ packages:
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
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':
|
'@esbuild/darwin-x64@0.25.4':
|
||||||
resolution: {integrity: sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==}
|
resolution: {integrity: sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -720,12 +678,6 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
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':
|
'@esbuild/freebsd-arm64@0.25.4':
|
||||||
resolution: {integrity: sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==}
|
resolution: {integrity: sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -738,12 +690,6 @@ packages:
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [freebsd]
|
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':
|
'@esbuild/freebsd-x64@0.25.4':
|
||||||
resolution: {integrity: sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==}
|
resolution: {integrity: sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -756,12 +702,6 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [freebsd]
|
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':
|
'@esbuild/linux-arm64@0.25.4':
|
||||||
resolution: {integrity: sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==}
|
resolution: {integrity: sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -774,12 +714,6 @@ packages:
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
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':
|
'@esbuild/linux-arm@0.25.4':
|
||||||
resolution: {integrity: sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==}
|
resolution: {integrity: sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -792,12 +726,6 @@ packages:
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
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':
|
'@esbuild/linux-ia32@0.25.4':
|
||||||
resolution: {integrity: sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==}
|
resolution: {integrity: sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -810,12 +738,6 @@ packages:
|
||||||
cpu: [ia32]
|
cpu: [ia32]
|
||||||
os: [linux]
|
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':
|
'@esbuild/linux-loong64@0.25.4':
|
||||||
resolution: {integrity: sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==}
|
resolution: {integrity: sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -828,12 +750,6 @@ packages:
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
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':
|
'@esbuild/linux-mips64el@0.25.4':
|
||||||
resolution: {integrity: sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==}
|
resolution: {integrity: sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -846,12 +762,6 @@ packages:
|
||||||
cpu: [mips64el]
|
cpu: [mips64el]
|
||||||
os: [linux]
|
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':
|
'@esbuild/linux-ppc64@0.25.4':
|
||||||
resolution: {integrity: sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==}
|
resolution: {integrity: sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -864,12 +774,6 @@ packages:
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
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':
|
'@esbuild/linux-riscv64@0.25.4':
|
||||||
resolution: {integrity: sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==}
|
resolution: {integrity: sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -882,12 +786,6 @@ packages:
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
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':
|
'@esbuild/linux-s390x@0.25.4':
|
||||||
resolution: {integrity: sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==}
|
resolution: {integrity: sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -900,12 +798,6 @@ packages:
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
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':
|
'@esbuild/linux-x64@0.25.4':
|
||||||
resolution: {integrity: sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==}
|
resolution: {integrity: sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -918,12 +810,6 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
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':
|
'@esbuild/netbsd-arm64@0.25.4':
|
||||||
resolution: {integrity: sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==}
|
resolution: {integrity: sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -936,12 +822,6 @@ packages:
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [netbsd]
|
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':
|
'@esbuild/netbsd-x64@0.25.4':
|
||||||
resolution: {integrity: sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==}
|
resolution: {integrity: sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -954,12 +834,6 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [netbsd]
|
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':
|
'@esbuild/openbsd-arm64@0.25.4':
|
||||||
resolution: {integrity: sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==}
|
resolution: {integrity: sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -972,12 +846,6 @@ packages:
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [openbsd]
|
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':
|
'@esbuild/openbsd-x64@0.25.4':
|
||||||
resolution: {integrity: sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==}
|
resolution: {integrity: sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -990,24 +858,12 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [openbsd]
|
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':
|
'@esbuild/openharmony-arm64@0.27.3':
|
||||||
resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
|
resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [openharmony]
|
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':
|
'@esbuild/sunos-x64@0.25.4':
|
||||||
resolution: {integrity: sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==}
|
resolution: {integrity: sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -1020,12 +876,6 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [sunos]
|
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':
|
'@esbuild/win32-arm64@0.25.4':
|
||||||
resolution: {integrity: sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==}
|
resolution: {integrity: sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -1038,12 +888,6 @@ packages:
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
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':
|
'@esbuild/win32-ia32@0.25.4':
|
||||||
resolution: {integrity: sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==}
|
resolution: {integrity: sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -1056,12 +900,6 @@ packages:
|
||||||
cpu: [ia32]
|
cpu: [ia32]
|
||||||
os: [win32]
|
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':
|
'@esbuild/win32-x64@0.25.4':
|
||||||
resolution: {integrity: sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==}
|
resolution: {integrity: sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -1546,23 +1384,6 @@ packages:
|
||||||
'@poppinss/exception@1.2.3':
|
'@poppinss/exception@1.2.3':
|
||||||
resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==}
|
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':
|
'@rtsao/scc@1.1.0':
|
||||||
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
||||||
|
|
||||||
|
|
@ -1887,11 +1708,6 @@ packages:
|
||||||
'@tailwindcss/postcss@4.1.18':
|
'@tailwindcss/postcss@4.1.18':
|
||||||
resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==}
|
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':
|
'@ts-morph/common@0.27.0':
|
||||||
resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==}
|
resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==}
|
||||||
|
|
||||||
|
|
@ -1916,9 +1732,6 @@ packages:
|
||||||
'@types/node@18.19.130':
|
'@types/node@18.19.130':
|
||||||
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
|
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
|
||||||
|
|
||||||
'@types/node@20.19.33':
|
|
||||||
resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==}
|
|
||||||
|
|
||||||
'@types/node@25.2.3':
|
'@types/node@25.2.3':
|
||||||
resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==}
|
resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==}
|
||||||
|
|
||||||
|
|
@ -1927,11 +1740,6 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/react': ^19.2.0
|
'@types/react': ^19.2.0
|
||||||
|
|
||||||
'@types/react-reconciler@0.28.9':
|
|
||||||
resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==}
|
|
||||||
peerDependencies:
|
|
||||||
'@types/react': '*'
|
|
||||||
|
|
||||||
'@types/react@19.2.14':
|
'@types/react@19.2.14':
|
||||||
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
||||||
|
|
||||||
|
|
@ -2288,11 +2096,6 @@ packages:
|
||||||
resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==}
|
resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
bippy@0.5.30:
|
|
||||||
resolution: {integrity: sha512-8CFmJAHD3gmTLDOCDHuWhjm1nxHSFZdlGoWtak9r53Uxn36ynOjxBLyxXHh/7h/XiKLyPvfdXa0gXWcD9o9lLQ==}
|
|
||||||
peerDependencies:
|
|
||||||
react: '>=17.0.1'
|
|
||||||
|
|
||||||
birecord@0.1.1:
|
birecord@0.1.1:
|
||||||
resolution: {integrity: sha512-VUpsf/qykW0heRlC8LooCq28Kxn3mAqKohhDG/49rrsQ1dT1CXyj/pgXS+5BSRzFTR/3DyIBOqQOrGyZOh71Aw==}
|
resolution: {integrity: sha512-VUpsf/qykW0heRlC8LooCq28Kxn3mAqKohhDG/49rrsQ1dT1CXyj/pgXS+5BSRzFTR/3DyIBOqQOrGyZOh71Aw==}
|
||||||
|
|
||||||
|
|
@ -2658,11 +2461,6 @@ packages:
|
||||||
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
|
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
esbuild@0.25.12:
|
|
||||||
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
esbuild@0.25.4:
|
esbuild@0.25.4:
|
||||||
resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==}
|
resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -2859,12 +2657,6 @@ packages:
|
||||||
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
|
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
|
||||||
engines: {node: '>=4.0'}
|
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:
|
esutils@2.0.3:
|
||||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -3979,10 +3771,6 @@ packages:
|
||||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||||
engines: {node: '>= 0.4'}
|
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:
|
postcss-selector-parser@7.1.1:
|
||||||
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
|
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
@ -3999,9 +3787,6 @@ packages:
|
||||||
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
|
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
preact@10.28.3:
|
|
||||||
resolution: {integrity: sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==}
|
|
||||||
|
|
||||||
prelude-ls@1.2.1:
|
prelude-ls@1.2.1:
|
||||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
@ -4123,13 +3908,6 @@ packages:
|
||||||
react-is@16.13.1:
|
react-is@16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
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:
|
react@19.2.4:
|
||||||
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
|
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -4547,9 +4325,6 @@ packages:
|
||||||
undici-types@5.26.5:
|
undici-types@5.26.5:
|
||||||
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
|
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
|
||||||
|
|
||||||
undici-types@6.21.0:
|
|
||||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
|
||||||
|
|
||||||
undici-types@7.16.0:
|
undici-types@7.16.0:
|
||||||
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||||
|
|
||||||
|
|
@ -4576,10 +4351,6 @@ packages:
|
||||||
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
unplugin@2.1.0:
|
|
||||||
resolution: {integrity: sha512-us4j03/499KhbGP8BU7Hrzrgseo+KdfJYWcbcajCOqsAyb8Gk0Yn2kiUIcZISYCb1JFaZfIuG3b42HmguVOKCQ==}
|
|
||||||
engines: {node: '>=18.12.0'}
|
|
||||||
|
|
||||||
unrs-resolver@1.11.1:
|
unrs-resolver@1.11.1:
|
||||||
resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==}
|
resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==}
|
||||||
|
|
||||||
|
|
@ -4625,9 +4396,6 @@ packages:
|
||||||
webidl-conversions@3.0.1:
|
webidl-conversions@3.0.1:
|
||||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
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:
|
whatwg-url@5.0.0:
|
||||||
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||||
|
|
||||||
|
|
@ -5816,231 +5584,153 @@ snapshots:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.25.12':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.25.4':
|
'@esbuild/aix-ppc64@0.25.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.27.3':
|
'@esbuild/aix-ppc64@0.27.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/android-arm64@0.25.12':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/android-arm64@0.25.4':
|
'@esbuild/android-arm64@0.25.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/android-arm64@0.27.3':
|
'@esbuild/android-arm64@0.27.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/android-arm@0.25.12':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/android-arm@0.25.4':
|
'@esbuild/android-arm@0.25.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/android-arm@0.27.3':
|
'@esbuild/android-arm@0.27.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/android-x64@0.25.12':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/android-x64@0.25.4':
|
'@esbuild/android-x64@0.25.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/android-x64@0.27.3':
|
'@esbuild/android-x64@0.27.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/darwin-arm64@0.25.12':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/darwin-arm64@0.25.4':
|
'@esbuild/darwin-arm64@0.25.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/darwin-arm64@0.27.3':
|
'@esbuild/darwin-arm64@0.27.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/darwin-x64@0.25.12':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/darwin-x64@0.25.4':
|
'@esbuild/darwin-x64@0.25.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/darwin-x64@0.27.3':
|
'@esbuild/darwin-x64@0.27.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/freebsd-arm64@0.25.12':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/freebsd-arm64@0.25.4':
|
'@esbuild/freebsd-arm64@0.25.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/freebsd-arm64@0.27.3':
|
'@esbuild/freebsd-arm64@0.27.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/freebsd-x64@0.25.12':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/freebsd-x64@0.25.4':
|
'@esbuild/freebsd-x64@0.25.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/freebsd-x64@0.27.3':
|
'@esbuild/freebsd-x64@0.27.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-arm64@0.25.12':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/linux-arm64@0.25.4':
|
'@esbuild/linux-arm64@0.25.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-arm64@0.27.3':
|
'@esbuild/linux-arm64@0.27.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-arm@0.25.12':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/linux-arm@0.25.4':
|
'@esbuild/linux-arm@0.25.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-arm@0.27.3':
|
'@esbuild/linux-arm@0.27.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-ia32@0.25.12':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/linux-ia32@0.25.4':
|
'@esbuild/linux-ia32@0.25.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-ia32@0.27.3':
|
'@esbuild/linux-ia32@0.27.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-loong64@0.25.12':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/linux-loong64@0.25.4':
|
'@esbuild/linux-loong64@0.25.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-loong64@0.27.3':
|
'@esbuild/linux-loong64@0.27.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-mips64el@0.25.12':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/linux-mips64el@0.25.4':
|
'@esbuild/linux-mips64el@0.25.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-mips64el@0.27.3':
|
'@esbuild/linux-mips64el@0.27.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-ppc64@0.25.12':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/linux-ppc64@0.25.4':
|
'@esbuild/linux-ppc64@0.25.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-ppc64@0.27.3':
|
'@esbuild/linux-ppc64@0.27.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-riscv64@0.25.12':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/linux-riscv64@0.25.4':
|
'@esbuild/linux-riscv64@0.25.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-riscv64@0.27.3':
|
'@esbuild/linux-riscv64@0.27.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-s390x@0.25.12':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/linux-s390x@0.25.4':
|
'@esbuild/linux-s390x@0.25.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-s390x@0.27.3':
|
'@esbuild/linux-s390x@0.27.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-x64@0.25.12':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/linux-x64@0.25.4':
|
'@esbuild/linux-x64@0.25.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-x64@0.27.3':
|
'@esbuild/linux-x64@0.27.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/netbsd-arm64@0.25.12':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/netbsd-arm64@0.25.4':
|
'@esbuild/netbsd-arm64@0.25.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/netbsd-arm64@0.27.3':
|
'@esbuild/netbsd-arm64@0.27.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/netbsd-x64@0.25.12':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/netbsd-x64@0.25.4':
|
'@esbuild/netbsd-x64@0.25.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/netbsd-x64@0.27.3':
|
'@esbuild/netbsd-x64@0.27.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/openbsd-arm64@0.25.12':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/openbsd-arm64@0.25.4':
|
'@esbuild/openbsd-arm64@0.25.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/openbsd-arm64@0.27.3':
|
'@esbuild/openbsd-arm64@0.27.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/openbsd-x64@0.25.12':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/openbsd-x64@0.25.4':
|
'@esbuild/openbsd-x64@0.25.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/openbsd-x64@0.27.3':
|
'@esbuild/openbsd-x64@0.27.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/openharmony-arm64@0.25.12':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/openharmony-arm64@0.27.3':
|
'@esbuild/openharmony-arm64@0.27.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/sunos-x64@0.25.12':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/sunos-x64@0.25.4':
|
'@esbuild/sunos-x64@0.25.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/sunos-x64@0.27.3':
|
'@esbuild/sunos-x64@0.27.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/win32-arm64@0.25.12':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/win32-arm64@0.25.4':
|
'@esbuild/win32-arm64@0.25.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/win32-arm64@0.27.3':
|
'@esbuild/win32-arm64@0.27.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/win32-ia32@0.25.12':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/win32-ia32@0.25.4':
|
'@esbuild/win32-ia32@0.25.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/win32-ia32@0.27.3':
|
'@esbuild/win32-ia32@0.27.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/win32-x64@0.25.12':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/win32-x64@0.25.4':
|
'@esbuild/win32-x64@0.25.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -6546,19 +6236,6 @@ snapshots:
|
||||||
|
|
||||||
'@poppinss/exception@1.2.3': {}
|
'@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': {}
|
'@rtsao/scc@1.1.0': {}
|
||||||
|
|
||||||
'@sec-ant/readable-stream@0.4.1': {}
|
'@sec-ant/readable-stream@0.4.1': {}
|
||||||
|
|
@ -6980,11 +6657,6 @@ snapshots:
|
||||||
postcss: 8.5.6
|
postcss: 8.5.6
|
||||||
tailwindcss: 4.1.18
|
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':
|
'@ts-morph/common@0.27.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-glob: 3.3.3
|
fast-glob: 3.3.3
|
||||||
|
|
@ -7013,10 +6685,6 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 5.26.5
|
undici-types: 5.26.5
|
||||||
|
|
||||||
'@types/node@20.19.33':
|
|
||||||
dependencies:
|
|
||||||
undici-types: 6.21.0
|
|
||||||
|
|
||||||
'@types/node@25.2.3':
|
'@types/node@25.2.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.16.0
|
undici-types: 7.16.0
|
||||||
|
|
@ -7025,10 +6693,6 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 19.2.14
|
'@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':
|
'@types/react@19.2.14':
|
||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
@ -7417,13 +7081,6 @@ snapshots:
|
||||||
|
|
||||||
baseline-browser-mapping@2.9.19: {}
|
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: {}
|
birecord@0.1.1: {}
|
||||||
|
|
||||||
blake3-wasm@2.1.5: {}
|
blake3-wasm@2.1.5: {}
|
||||||
|
|
@ -7824,35 +7481,6 @@ snapshots:
|
||||||
is-date-object: 1.1.0
|
is-date-object: 1.1.0
|
||||||
is-symbol: 1.1.1
|
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:
|
esbuild@0.25.4:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@esbuild/aix-ppc64': 0.25.4
|
'@esbuild/aix-ppc64': 0.25.4
|
||||||
|
|
@ -8233,12 +7861,6 @@ snapshots:
|
||||||
|
|
||||||
estraverse@5.3.0: {}
|
estraverse@5.3.0: {}
|
||||||
|
|
||||||
estree-walker@2.0.2: {}
|
|
||||||
|
|
||||||
estree-walker@3.0.3:
|
|
||||||
dependencies:
|
|
||||||
'@types/estree': 1.0.8
|
|
||||||
|
|
||||||
esutils@2.0.3: {}
|
esutils@2.0.3: {}
|
||||||
|
|
||||||
etag@1.8.1: {}
|
etag@1.8.1: {}
|
||||||
|
|
@ -9323,11 +8945,6 @@ snapshots:
|
||||||
|
|
||||||
possible-typed-array-names@1.1.0: {}
|
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:
|
postcss-selector-parser@7.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
cssesc: 3.0.0
|
cssesc: 3.0.0
|
||||||
|
|
@ -9347,8 +8964,6 @@ snapshots:
|
||||||
|
|
||||||
powershell-utils@0.1.0: {}
|
powershell-utils@0.1.0: {}
|
||||||
|
|
||||||
preact@10.28.3: {}
|
|
||||||
|
|
||||||
prelude-ls@1.2.1: {}
|
prelude-ls@1.2.1: {}
|
||||||
|
|
||||||
prettier-plugin-organize-imports@4.3.0(prettier@3.8.1)(typescript@5.9.3):
|
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-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: {}
|
react@19.2.4: {}
|
||||||
|
|
||||||
recast@0.23.11:
|
recast@0.23.11:
|
||||||
|
|
@ -9982,8 +9573,6 @@ snapshots:
|
||||||
|
|
||||||
undici-types@5.26.5: {}
|
undici-types@5.26.5: {}
|
||||||
|
|
||||||
undici-types@6.21.0: {}
|
|
||||||
|
|
||||||
undici-types@7.16.0: {}
|
undici-types@7.16.0: {}
|
||||||
|
|
||||||
undici@7.16.0: {}
|
undici@7.16.0: {}
|
||||||
|
|
@ -10000,12 +9589,6 @@ snapshots:
|
||||||
|
|
||||||
unpipe@1.0.0: {}
|
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:
|
unrs-resolver@1.11.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
napi-postinstall: 0.3.4
|
napi-postinstall: 0.3.4
|
||||||
|
|
@ -10060,9 +9643,6 @@ snapshots:
|
||||||
|
|
||||||
webidl-conversions@3.0.1: {}
|
webidl-conversions@3.0.1: {}
|
||||||
|
|
||||||
webpack-virtual-modules@0.6.2:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
whatwg-url@5.0.0:
|
whatwg-url@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
tr46: 0.0.3
|
tr46: 0.0.3
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,2 @@
|
||||||
/_next/static/*
|
/_next/static/*
|
||||||
Cache-Control: public, max-age=31536000, immutable
|
Cache-Control: public, max-age=31536000, immutable
|
||||||
|
|
||||||
/video.webm
|
|
||||||
Cache-Control: public, max-age=2592000
|
|
||||||
|
|
||||||
/logo.svg
|
|
||||||
Cache-Control: public, max-age=2592000
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@
|
||||||
@import 'tw-animate-css';
|
@import 'tw-animate-css';
|
||||||
@import 'shadcn/tailwind.css';
|
@import 'shadcn/tailwind.css';
|
||||||
|
|
||||||
@plugin '@tailwindcss/typography';
|
|
||||||
|
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,12 @@
|
||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
import { MotionConfig } from 'motion/react'
|
import { MotionConfig } from 'motion/react'
|
||||||
import { ThemeProvider } from 'next-themes'
|
import { ThemeProvider } from 'next-themes'
|
||||||
import { useEffect } from 'react'
|
|
||||||
|
|
||||||
interface ProvidersProps {
|
interface ProvidersProps {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Providers({ children }: ProvidersProps) {
|
export function Providers({ children }: ProvidersProps) {
|
||||||
useEffect(() => {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
void import('react-scan').then(({ scan }) => {
|
|
||||||
scan({ enabled: true })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MotionConfig reducedMotion='user'>
|
<MotionConfig reducedMotion='user'>
|
||||||
<ThemeProvider attribute='class' disableTransitionOnChange>
|
<ThemeProvider attribute='class' disableTransitionOnChange>
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,6 @@
|
||||||
// "bucket_name": "<BUCKET_NAME>",
|
// "bucket_name": "<BUCKET_NAME>",
|
||||||
// },
|
// },
|
||||||
// ],
|
// ],
|
||||||
"images": {
|
|
||||||
// Enable image optimization
|
|
||||||
// see https://opennext.js.org/cloudflare/howtos/image
|
|
||||||
"binding": "IMAGES",
|
|
||||||
},
|
|
||||||
"minify": true,
|
"minify": true,
|
||||||
"keep_names": false,
|
"keep_names": false,
|
||||||
"upload_source_maps": true,
|
"upload_source_maps": true,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue