Compare commits

...
Sign in to create a new pull request.

119 commits

Author SHA1 Message Date
378c230815 release v5.3.1
All checks were successful
Actions probe / probe (push) Successful in 0s
Release APK / Build & publish release (push) Successful in 7m46s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 10:21:21 +02:00
eb39a4ac00 Auto-recover native player after Source errors.
Screen-off and network switches often leave ExoPlayer dead without a 4xx status; refresh automatically (and on app resume) instead of waiting for Reload.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 10:21:21 +02:00
59290b23b1 release v5.3.0
Some checks failed
Actions probe / probe (push) Successful in 0s
Release APK / Build & publish release (push) Failing after 9m42s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 09:49:52 +02:00
9a08c40136 Add Follow/Unfollow button under the channel player.
Show channel info and a follow control between video and chat in portrait, reusing the shared GQL follow path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 09:49:45 +02:00
9ced2fe212 release v5.2.9
All checks were successful
Actions probe / probe (push) Successful in 0s
Release APK / Build & publish release (push) Successful in 8m10s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 07:32:16 +02:00
3fb393166f Fix tag directory sorting and follow Client-Integrity.
Paginate tag (and ASC) lists via GQL with integrity retry; mint follow
integrity from a WebView because Dio /integrity tokens are bad_bot.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 07:32:16 +02:00
383e786479 release v5.2.8
All checks were successful
Actions probe / probe (push) Successful in 0s
Release APK / Build & publish release (push) Successful in 7m45s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 03:06:09 +02:00
30848e6fe8 Add follow and unfollow for live and offline channels.
Long-press actions use Helix for follow status and web GQL mutations via the linked session.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 03:06:05 +02:00
318dc84dc5 Fix tag infinite scroll after GQL ASC first page.
All checks were successful
Actions probe / probe (push) Successful in 0s
Release APK / Build & publish release (push) Successful in 7m37s
GQL page-2 fails integrity, so fewest starts with GQL then continues via Helix tag filter with dedupe; empty Helix windows no longer end the list while a cursor remains.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 12:23:15 +02:00
5a787e0134 Fix fewest-viewers sort via GQL VIEWER_COUNT_ASC.
All checks were successful
Actions probe / probe (push) Successful in 0s
Release APK / Build & publish release (push) Successful in 8m16s
Helix directories are viewer-desc only; local reverse of top pages wrongly showed high-viewer streams for ascending sort.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 12:03:12 +02:00
47150d0ab3 Fix tag pagination and persist viewer sort.
All checks were successful
Actions probe / probe (push) Successful in 0s
Release APK / Build & publish release (push) Successful in 7m59s
Load tag (and language) directories via Helix with client-side tag matching so scroll can continue past the GQL integrity wall. Re-sort lists locally after each fetch and store streamViewerSort in Settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 11:41:25 +02:00
9d392d8d6a Add stream language filter and fix directory infinite scroll.
All checks were successful
Actions probe / probe (push) Successful in 0s
Release APK / Build & publish release (push) Successful in 7m41s
Settings multi-select filters Top/category/tag lists via Helix (with over-fetch for multiple languages). Larger Helix pages, safer cursors, and scroll near-end loading restore pagination; GQL page-2 integrity failures end the list cleanly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 10:51:10 +02:00
cff29232c7 Fix NativeVideoStore isBlockingAds for release builds.
All checks were successful
Actions probe / probe (push) Successful in 0s
Release APK / Build & publish release (push) Successful in 8m20s
build_runner had stripped a hand-patched override from the generated store; implement the VideoPlayerInterface getter on the hand-written base class so assembleRelease succeeds.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 23:25:33 +02:00
d8247843c4 Add freeform tag search and viewer sort for live lists.
Some checks failed
Actions probe / probe (push) Successful in 0s
Release APK / Build & publish release (push) Failing after 24s
Helix cannot filter by tags or sort ascending; use Twitch GQL for Search→Tags→streams and Most/Fewest viewers on Top, category, and tag screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 23:22:56 +02:00
7863935fff ci: add temporary Actions probe workflow
All checks were successful
Actions probe / probe (push) Successful in 0s
Verify Forgejo schedules jobs for this repo before Obtainium release builds.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 11:36:53 +02:00
2775847c6d release v5.2.1
All checks were successful
Release APK / Build & publish release (push) Successful in 7m19s
Ship the first Obtainium build of this Forgejo fork with TwitchAdSolutions ad blocking.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 11:36:03 +02:00
a1b9a5e46d Add script to mint and upload Forgejo Obtainium release secrets.
Generates a local upload keystore and pushes Actions secrets without printing values, so Obtainium APK builds can be signed on desktop-release.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 11:34:44 +02:00
cdbccc23a8 Add Forgejo Actions release workflow for Obtainium APK updates.
Build a signed Flutter APK on desktop-release and publish versioned assets so private-repo Obtainium can track frosty-arm64-v8a-v*.apk.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 10:59:23 +02:00
1aae7438bb Add TwitchAdSolutions-style ad blocking for native and WebView players.
Some checks failed
CI / Analyze, test, and build Android (push) Has been cancelled
CI / Build iOS (push) Has been cancelled
Request playback with non-site player types and swap to backup Usher streams on stitched ad breaks; inject vendored vaft into the legacy WebView and expose a Settings toggle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 10:55:50 +02:00
Tommy Chow
bb150ce42a chore(mcp): share dart server across grok, claude, and cursor
Keep Next DevTools in both project MCP files so Cursor matches Claude's .mcp.json.
2026-08-23 11:08:03 -07:00
Tommy Chow
c50b3c3c69 fix: correct typos and grammar in comments and login copy
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 19:07:42 -07:00
Tommy Chow
a6d7ae6916 chore(store): drop brand names from listing title and short copy
Play rejected Twitch, 7TV, BTTV, and FFZ in the visible listing fields. Keep iOS aligned with the same card.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 18:52:05 -07:00
Tommy Chow
96b3eca604 release v5.2.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 12:36:33 -07:00
Tommy Chow
70de25b29b build(deps): pick up compatible lockfile bumps
webview_flutter_android 4.14.0 is the only direct-ish change. Held
secure_storage / plus packages / wakelock 1.7 for the win32 pin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 12:33:59 -07:00
Tommy Chow
1db8b95ba4 docs(agents): keep in-app notes to three consecutive versions
Drop from the oldest end so the file stays short without holes in
the version list.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 12:26:57 -07:00
Tommy Chow
fcd7a304e8 feat(video): make the native player the default
5.1.0 already serialized useNativePlayer: false, so existing installs
need a one-shot migrate. WebView stays as a legacy opt-out.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 12:21:44 -07:00
Tommy Chow
dc444810b1 docs(agents): split the release ritual into a repo-local playbook
The full release procedure was loading in every Frosty session. Same split
as the dotfiles resync playbook: always-on file stays gotchas, the ritual
lives in docs/release.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 11:06:26 -07:00
Tommy Chow
0937a1c5ad
build(deps): bump Flutter to 3.47.0 and Android Gradle stack (#590)
* build(deps): bump Flutter pin to 3.47.0 and Android Gradle stack

Pin Flutter 3.47.0 (Dart 3.13) as the CI source of truth and move the
Android toolchain to the 3.47 template versions: Gradle 9.3.1, AGP 9.1.0,
Kotlin 2.4.0.

App Gradle script matches the 3.47 template (drop kotlin-android /
kotlinOptions, use compilerOptions). Keep android.builtInKotlin=false and
android.newDsl=false so plugins that still apply KGP keep building.

AGP 9 requires libraries to compile against their dependencies' compileSdk;
force plugin modules to 36 via finalizeDsl. Ignore android/build/, which
AGP 9 now writes a problems report into.

analyze, test, and a release APK all passed. 16 KB zip alignment verified.

iOS UIScene is not in this commit: AppDelegate is customized
(CookieExtractorPlugin), so Flutter will not auto-migrate.

* ci: drop unused Claude Code GitHub Actions

Remove the PR review workflow and the @claude mention bot. Neither is
used anymore; the review job just failed this PR on an expired OAuth
token.

* feat(ios): adopt UIScene lifecycle for Flutter 3.47

Adopt UIApplicationSceneManifest and FlutterImplicitEngineDelegate in AppDelegate to support the UIScene lifecycle requirement on iOS.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 14:51:05 -07:00
Tommy Chow
0e26aefeb3 build(ios): refresh CocoaPods checksums and Firebase SPM pins
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 15:34:01 -07:00
Tommy Chow
cc772a7c90 fix(auth): request moderator OAuth scopes only on opt-in
Stop asking for mod scopes at normal login so existing core-only tokens
never hit a missing-permissions dialog. Gate moderated-channels Helix,
offer an Enable flow for mods/broadcasters, and upgrade atomically.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 15:33:06 -07:00
Tommy Chow
2d65aeec29 fix(settings): limit web-session dialog to the profile status icon
Tapping the display name or surrounding whitespace was opening the web-session dialog; only the checkmark/info icon should.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 15:18:06 -07:00
Tommy Chow
9888985deb fix(auth): list missing Twitch scopes in the re-login dialog
Existing tokens lack the newer mod scopes, so Helix 401s looked like a vague permissions failure. Cache scopes from /oauth2/validate and show which ones need re-authorization.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 14:48:55 -07:00
Tommy Chow
11cd05e6a8 docs: drop removed build_runner flag from AGENTS.md
`--delete-conflicting-outputs` was removed in build_runner 2.15 and is
now ignored with a warning; conflicting outputs are handled
automatically. Documenting it kept sending agents down a no-op path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 13:18:10 -07:00
Tommy Chow
40733756ab build(deps): bump flutter pin to 3.44.9 and upgrade dependencies
The pubspec pinned Flutter 3.44.5 while the toolchain had moved to
3.44.9, so `flutter pub upgrade` failed version solving outright and no
dependency had been upgradable for a while. Bumping the pin unblocks
resolution (CI reads the same field via flutter-version-file).

Notable bumps: dio 5.9.2 -> 5.11.0, firebase_core 4.10 -> 4.13 (plus
crashlytics/analytics/performance), app_links 7.2.1, flutter_markdown_plus
1.0.12, intl, path_provider, webview_flutter, flutter_cache_manager.

dio 5.11 adds DioExceptionType.transformTimeout, which broke the
exhaustive switch in BaseApiClient._handleError. Grouped it with the
other timeout types and covered all four mappings with tests, which
were previously untested.

Raised the json_annotation floor to ^4.12.0 to silence a
json_serializable build warning; the resolved version was already 4.12.0.

flutter_secure_storage stays on v9 deliberately. v10.0.0 shipped in 5.0.0
and was reverted in 5.0.1 (ba28e09) while chasing the #484 startup freeze
on low-end Android 14, and upstream #1084 (unrecoverable NPE in
initialize() on Android 13) is still open. v9 pins win32 ^5, so
device_info_plus, package_info_plus and wakelock_plus are held at their
current majors too - their only breaking change was a win32 6.0.0 bump
that affects Windows only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 13:17:46 -07:00
Tommy Chow
a21cf192eb ci: drop the dry-run failure notifier and run it monthly
Reverts the tracking-issue job from 601088b: a single maintainer watching
the Actions tab doesn't need a bot filing issues at them, and GitHub
already emails on scheduled workflow failure.

Also moves the heartbeat from weekly to monthly. Nothing it watches --
cert expiry, secret rotation, dependency drift -- moves on a weekly
timescale, and a dispatch run before cutting a release is the check that
actually matters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 16:15:52 -07:00
Tommy Chow
601088b5c6 ci: bump ruby to 3.3 and report scheduled dry-run failures
fastlane warns on every run that it will soon require Ruby 3.3.0 or
newer; both release workflows pinned 3.2. Bumping now means that break
lands on a quiet commit instead of mid-release. fastlane 2.234 already
supports 3.3 and no gem in either lockfile is version-sensitive, so this
stands alone — the lockfiles are unchanged.

Also gives the weekly dry-run somewhere to report. It broke on the
release after 5.1.0 and stayed red for weeks without surfacing, which is
the failure mode of any heartbeat nobody reads: it looks green by
default. Scheduled failures now open a tracking issue, or comment on the
existing one for each week it stays red. Dispatched runs are excluded —
those are already in front of whoever started them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 16:04:15 -07:00
Tommy Chow
c939dc2790 ci: give the release dry-run an unused android version code
Play rejects a version code it has already seen, and between releases
pubspec's code is by definition already published — so every scheduled
dry-run died on "Version code 69 has already been used" before reaching
any of the credential, signing, or store validation it exists to check.
It has been failing on that since 5.1.0 shipped.

Overrides the build number with epoch seconds, which is always unused and
always increasing. The bundle is validate_only, never published, so the
value is discarded. Only downside is the dry-run can no longer catch a
forgotten version bump, which the real release catches anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 15:43:51 -07:00
Tommy Chow
36aa5e7368 fix(video): regenerate video store bindings
880a9c9 added the handleAndroidPipChanged and _setPipActive actions to
VideoStoreBase without regenerating the mixin, so neither was wrapped by
the MobX action controller. Both still ran, but their observable writes
landed as separate notifications instead of one batched transaction —
extra rebuilds on every PiP transition. Analyze and the test suite pass
either way; only CI's generated-file diff check catches it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 15:43:35 -07:00
Tommy Chow
14591b3bf8 build(video): pull in native player low-latency HLS
Bumps better_native_video_player to the frosty-patches tip, which now
carries the Twitch low-latency work: #EXT-X-TWITCH-PREFETCH segments are
promoted into real segments, EXT-X-TARGETDURATION is rewritten to the true
~2s cadence so media3 stops starving on reloads, and the LoadControl no
longer parks playback ~5s behind after a rebuffer.

Takes native live latency on Android from ~9s to ~3s. iOS is unaffected —
AVFoundation parses HLS internally, so the same rewrite needs an
AVAssetResourceLoaderDelegate to intercept the playlist.

Note the readout: getLatencyToLive() measures distance to the playlist's
live edge, while Twitch's own "Latency To Broadcaster" stat adds ingest and
packaging on top, so the two aren't directly comparable.

Refs #573

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 15:20:52 -07:00
Tommy Chow
880a9c92aa fix(video): sync android pip state and playback across both players
Three related PiP gaps on Android:

`handleAndroidPipChanged` lived on `NativeVideoPlayerInterface`, and
`notifyPip` type-checked against it, so the WebView store never learned it
was in PiP — `_isInPipMode` stayed false for the whole session and the
overlay kept auto-hiding behind the PiP window. Moved to the shared
`VideoPlayerInterface` and implemented in both stores. The WebView store's
two JS PiP channel bodies collapse into one `_setPipActive` state machine,
now shared with the Android callback.

`NativeVideoStore` never called `_pip.setIsPlaying()`, so with the
`mediaOnlyPause` layout the PiP window's button was stuck on whatever glyph
it started with, and `onPipAction` — which toggles blind — could do the
opposite of what the button showed.

`VideoStore.handleAppResume()` did nothing in video mode, while the native
store calls `play()`. Backgrounding can suspend the webview's playback with
no way for the page to restart itself, leaving it dead until a manual
refresh. Resumes now unless the user was the one who paused.

Not fixed: PiP with the WebView player. Twitch's embed refuses to play
below 400x300 CSS pixels and a PiP window is far under that, Android
resizes the activity before firing onPictureInPictureModeChanged so any
correction arrives too late, and the embed latches once tripped. The native
player has no such constraint. Refs #563.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 11:47:54 -07:00
Tommy Chow
2a203aa0d1 fix(video): re-run player init on every webview page load
`_needsInit` replaced the JS-side `window._injected` guard in 9cec89a, but
the two have different lifetimes. `window._injected` died with the page, so
any page load re-ran `initVideo()`. `_needsInit` lives on the store and was
only re-armed by `handleRefresh()`.

The store outlives the Video widget, which loads `videoUrl` on every mount.
Those reloads hit `onPageFinished` with `_needsInit` already false and
returned early, so the player came back with no play/pause listeners, no
cached `_videoEl`, no autoplay call and no overlay CSS — visibly stuck
paused with a play button that did nothing, unrecoverable without an app
restart.

Re-arm on `onPageStarted` so every navigation re-initializes, restoring the
page-scoped lifetime while keeping the Dart-side control that fixed the
original race.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 11:47:42 -07:00
Tommy Chow
b2a32d178f build: bump flutter pin to 3.44.5
picks up the Impeller text/icon shadow positioning fix from #188035.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 00:13:05 -07:00
Tommy Chow
abb6cbc446
feat(chat): add bubbles for shared chat participant channels (#581)
* feat(chat): add bubbles for shared chat participant channels

Twitch's native shared chat previously had no way to interact with
participant channels the way manually-added chats work. Adds
per-participant bubbles alongside manual tabs: tapping one spotlights
that channel (fading the rest), hiding one filters its messages from
the feed entirely. Defaults to unfaded, resets on rejoin (fresh
ChatStore per connect).

Manual "merged mode" fading is untouched and unaffected.

* refactor(chat): dedup shared-chat tag lookup and extract fade policy

- add IRCMessage.sourceChannelId, replacing three copies of the same
  source-room-id/room-id tag lookup
- move the shared-chat spotlight/merged-mode fade decision out of
  ChatMessage's build into ChatStore.shouldFadeMessage, matching the
  extract-into-store convention already used for SharedChatBubbleState
- factor the tab-bar item's Padding/Center wrapper into a shared
  _wrapItem helper instead of duplicating it per branch

* fix(chat): don't spotlight a hidden shared-chat bubble

toggleFocus didn't check hiddenChannelIds, so tapping a hidden
bubble's chip added it to the spotlight set — fading every other
participant to showcase a channel whose own messages were already
filtered out of the feed.

* fix(chat): stop shared-chat detection flapping on system messages

_isInSharedChatMode was recomputed from every PRIVMSG/NOTICE/USERNOTICE,
but only PRIVMSG reliably carries source-room-id for a session's
duration. A sub/raid/announcement without the tag would flip the flag
off and back on within a single live session. Previously this only
risked a redundant asset refetch (already guarded); now the shared
chat bubble bar reads the flag directly, so a flap would visibly
collapse and reopen it. Scope detection to PRIVMSG only.
2026-07-03 00:16:49 -04:00
Tommy Chow
98c3a031bc docs(agents): drop duplicated never section and checklist meta-advice, flatten layout
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:25:11 -04:00
Tommy Chow
fb3508f43e docs(agents): drop pre-push check, CI gates instead
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:07:15 -04:00
Tommy Chow
65974ef954 docs(agents): add fork-pin gotcha, test layout, pre-push check
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:07:15 -04:00
Tommy Chow
e2dff06f8d docs(agents): fix dart-define names, default to tests, drop lint-enforced style rules
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:07:15 -04:00
Tommy Chow
2cdd3e9fda build: pin exact flutter version in pubspec as single source of truth
- add `flutter: 3.44.2` to environment so `flutter pub get` fails locally
  the moment the SDK drifts (was already drifting: local 3.44.2 vs the
  3.44.1 pinned in CI)
- point all 8 workflow pins at `flutter-version-file: pubspec.yaml` so CI
  reads the same field; bumping Flutter is now a one-line pubspec edit
  instead of editing 5 workflow files

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 19:52:09 -04:00
Tommy Chow
03e4198fdd ci: fail on stale generated files
run build_runner in both ci.yml (push to main) and pr-check.yml (PRs),
then fail if git status is dirty. catches forgetting to regenerate a
.g.dart after editing a MobX store or @JsonSerializable model, which
analyze/test/build all miss (as #576 did).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 18:51:11 -04:00
Tommy Chow
64c748d0b6 fix(chat): paginate moderated channels and refresh mod status on entry
- getModeratedChannels now follows the `after` cursor so moderators of
  more than 100 channels aren't silently truncated at the first page
  (mirrors getUserBlockedList's throttled recursion)
- add refreshModeratedChannels() and fire it on channel entry so mod
  status reflects grants/revokes made since login
- regenerate user_store.g.dart, which also restores #576's moderation
  action wrappers and the _moderatedChannels atom (its generated file
  was never regenerated when #576 merged)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 18:51:04 -04:00
Tommy Chow
66932c5d0b
fix(chat): keep long-press from missing in fast-moving chat (#579)
Freeze the message list on a stationary touch-and-hold (pointer-counted,
touch-slop aware) so an incoming message can't shift the row out from
under a long-press, while still allowing scroll to cancel the freeze.

Co-authored-by: Francis Lavoie <lavofr@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 18:15:42 -04:00
Francis Lavoie
2511ae93fd
feat(chat): moderator actions (delete / timeout / ban / unban) (#576)
Add a reusable ModerationActions widget shown to channel moderators (and
the broadcaster) from both the message long-press menu and the user
panel. Supports delete message, timeout (10 min), ban, and remove
ban/timeout — the recovery action appears only when the target is
currently banned or timed out (tracked via CLEARCHAT). Surfaces Twitch's
own error reason when an action is rejected, and forces an immediate
chat re-render so a deleted/banned message reflects instantly.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 17:43:37 -04:00
Francis Lavoie
b8648426f8
fix(video): keep native player alive across rotation (#575)
Wrap the native player in a GlobalKey so an orientation change re-parents
the existing platform view instead of tearing it down and reloading the
stream.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 17:23:26 -04:00
Tommy Chow
ef1b1ded5d fix: reclassify transient errors as non-fatal and fix top crashes
- main: record transient I/O, plugin-teardown, and non-crashing UI errors
  (network/websocket/timeout, MissingPluginException, image-load, layout
  overflow) as non-fatal, and add a grouping reason to async errors so
  distinct errors stop collapsing into one catch-all Crashlytics issue
- irc: guard IRCMessage.fromString against malformed/truncated lines that
  threw RangeError, with regression tests
- chat: wrap the emote menu in OverflowBox so it no longer overflows
  (RenderFlex) while the panel collapses
- chat: fix onBadgeError returning a List instead of a Map, which threw a
  Future.catchError type error during channel asset loads
- video: skip setAutoPipMode on Android < 12 where it isn't implemented

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 15:04:11 -04:00
Tommy Chow
6c969b0d97 release v5.1.0 2026-06-13 16:18:02 -07:00
Tommy Chow
d19ac38d6b ci(workflows): align xcode setup and refresh locks 2026-06-13 15:52:15 -07:00
Tommy Chow
f2b932bc93
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)
2026-06-13 17:06:24 -04:00
Tommy Chow
8883af90f0 fix(ios): resolve Crashlytics upload-symbols for SPM builds
The Crashlytics 'Upload Symbols' build phase was hardcoded to the CocoaPods
path ($PODS_ROOT/FirebaseCrashlytics/upload-symbols), but Flutter 3.44 +
firebase_crashlytics 5.x resolve Firebase via Swift Package Manager, so that
path no longer exists and every release build (including fastlane build_app)
failed with 'No such file or directory'. Fall back to the SPM checkout path
when the pod isn't present so the upload works under either integration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 23:39:51 -07:00
Tommy Chow
7f4abdefea ci: add release dry-run workflow and release-mode compile checks
Add a weekly + manual release-dryrun workflow that exercises the full store
pipeline without publishing: Android validates the AAB against Google Play
(validate_only), iOS builds a fully signed archive via match (readonly) and
skips the App Store upload. Both Fastlane lanes gain a dry_run option that
reuses the real release path so the dry-run stays faithful to it.

Also add release-mode compile checks (flutter build apk/ios --release) to CI
and PR checks to catch release-only breakage, pinning the JDK to match the
release toolchain. Trim ci.yml from a redundant 3-OS matrix to two
purpose-built jobs (analyze/test once on Linux, iOS build on macOS).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 23:15:38 -07:00
Tommy Chow
17bbb20b94 build(android): guard release signing to allow keyless compile checks
Release builds now use the real upload key when key.properties is present,
fall back to debug signing only when ALLOW_DEBUG_SIGNED_RELEASE is set (CI
compile-only checks), and otherwise fail loudly. Lets CI compile a release
APK without production secrets while ensuring a real release can never be
silently debug-signed if the keystore step goes missing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 23:15:38 -07:00
Tommy Chow
f5718e3656 fix(chat): restore channel assets when leaving shared chat
When a shared chat session ended, the participants' merged emotes and badges
stayed in the channel asset maps, so the other streamer's emote set and badges
kept showing (#522). Only the enter transition was handled.

Detect the leave transition (was shared, no longer shared) and reset: clear the
loaded-participant tracking and re-run getAssets(), which replaces the channel
emote/badge maps with this channel's own assets. Guarded on having actually
loaded shared assets so a flapping source-room-id tag can't cause redundant
refetches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:42:37 -07:00
Tommy Chow
29c4f74ef5 ci: pin flutter to 3.44.1 in all workflows
Workflows used subosito/flutter-action with no version, silently tracking
GitHub's latest stable. When stable rolled to 3.44.1 it broke the build with
no code change. Pin explicitly so builds are reproducible and Flutter upgrades
are a deliberate bump rather than a surprise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:33:24 -07:00
Tommy Chow
a3c9ca88f8 chore(deps): support flutter 3.44 stable (simple_icons, extended_text_field)
The CI/release toolchain uses the latest stable Flutter, now 3.44.1, which
broke two dependencies:

- simple_icons 14.6.1 extends IconData, now a final class -> bump to 16.23.0.
- extended_text_field 16.0.2 (latest pub release) vendors an EditableText copy
  referencing ExtendSelectionByPageIntent, removed in 3.44 -> point at a fork
  (tommyxchow/extended_text_field@76305e9) that drops the unused handler, pinned
  by commit SHA.

Restores flutter analyze (no issues) and flutter test (all pass) on 3.44.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:29:20 -07:00
Tommy Chow
d2b4f0f39e chore(chat): migrate reorderable tab list to onReorderItem for flutter 3.44
ReorderableListView's onReorder was deprecated after Flutter 3.41 and
fails flutter analyze on the CI's pinned 3.44.1 toolchain. Switch to
onReorderItem, which adjusts newIndex for the removed item itself, and
drop the now-redundant manual off-by-one correction in reorderTab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:01:47 -07:00
Tommy Chow
e24491639d docs: adopt conventional commit format in AGENTS.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:01:47 -07:00
Tommy Chow
c27a37d52f fix recent messages by handling valueless and escaped irc tags
The recent-messages API now serializes empty IRCv3 tags as bare keys
(e.g. `emotes`) instead of `emotes=`, which threw a RangeError in the
tag parser and silently dropped every recent message. Parse tags per the
IRCv3 spec: treat bare and empty values as equivalent, split on the first
`=` so values may contain `=`, and unescape values in a single pass
(\: \s \ \r \n). Also guard user extraction against prefixes without
`!`, and isolate per-message parse failures in _handleIRCData so one bad
message can no longer abort an entire batch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:53:53 -07:00
Tommy Chow
2b81f2cb31
Add Claude Code GitHub Workflow (#534)
* "Claude PR Assistant workflow"

* "Claude Code Review workflow"
2026-03-15 01:54:26 -04:00
Tommy Chow
7772c4d9f5 disable image optimization until free transformation quota resets
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:49:57 -07:00
Tommy Chow
092cbc3cbb add sizes prop to carousel images to reduce Cloudflare transformations
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:40:32 -07:00
Tommy Chow
11d4b30a09 enable sandbox with weaker network isolation for Go tool TLS support
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:16:12 -07:00
Tommy Chow
c58c9a9a25 guard crashlytics call in error handler for test environments
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:55:59 -07:00
Tommy Chow
5be231da73 fix landscape safe area padding across settings and list screens
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:04:00 -07:00
Tommy Chow
1dab572280 remove redundant edge borders where blur effect provides separation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:48:14 -07:00
Tommy Chow
872362d5a6 fix recent searches not saving from type-to-search
History was only saved via handleQuery (keyboard submit), but
type-to-search calls _performSearch directly through the debounce
timer, bypassing history. Move the save into _performSearch so all
search paths record history.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:33:17 -07:00
Tommy Chow
6ede000c66 improve Firebase integration across Crashlytics, Analytics, and Performance
- Fix cold-start privacy bug: apply persisted collection preference on launch
- Disable Firebase collection in debug builds to avoid polluting prod data
- Add Crashlytics custom keys (is_logged_in, channel), user identifier, and
  WebSocket reconnection breadcrumbs for actionable crash reports
- Record non-fatal errors for server failures, WebSocket errors, and global
  asset fetch failures (skip timeouts/network errors to avoid noise)
- Update is_logged_in key and user identifier on login/logout transitions
- Log auth reconnection exhaustion before forced logout
- Add FirebaseAnalyticsObserver with named routes for automatic screen tracking
- Add static routeName constants to avoid duplicated string literals
- Add Firebase Performance Dio interceptor for automatic HTTP request tracing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 00:18:21 -08:00
Tommy Chow
f07fe621bc fix stale fullscreen button icon and system UI mode not reacting to settings
Wrap fullScreenButton in Observer so icon/tooltip update immediately
when toggling fullscreen. Move system UI immersive mode sync into the
videoChat Observer so it reacts to landscapeForceVerticalChat changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 23:46:41 -08:00
Tommy Chow
820e563516 upgrade dependencies and regenerate codegen
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 23:42:51 -08:00
Tommy Chow
eb91146f63 fix auto sync chat delay not applying when toggled on mid-session
When showLatency was already tracking, toggling autoSyncChatDelay on
would wait up to 60s for the next tracker cycle instead of using the
cached latency value immediately.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 23:34:01 -08:00
Tommy Chow
9cec89a67c fix video refresh race condition causing black screen with play button
Replace JS-side `window._injected` guard with a Dart-controlled `_needsInit`
flag so `initVideo()` reliably runs after every refresh. The previous approach
loaded `about:blank` then `videoUrl` back-to-back, but `loadRequest` resolves
on navigation start (not page load), so the JS context often survived and
`onPageFinished` skipped re-initialization. Also adds an explicit `play()`
call to handle cases where the Twitch player loads in a paused state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 23:22:44 -08:00
Tommy Chow
929d525c13 add error handling to getRecentMessage and getAssets in chat store
getRecentMessage had no try/catch, so a failure from the third-party
API would silently prevent connectToChat from ever being called.
getAssets was fire-and-forget without a catchError, risking uncaught
Future errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 20:46:40 -08:00
Tommy Chow
7a9af238b6 switch token script to implicit flow and fix interceptor hanging Future
The script used authorization code flow (response_type=code) which produces
tokens expiring in ~4 hours, while the app uses implicit flow (~60 days).
Switched the script to implicit flow to match, removing the client_secret
requirement and token exchange step.

Also fixed UnauthorizedInterceptor swallowing 401 errors without calling
handler.reject(), which left Dio request Futures hanging forever.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 20:37:23 -08:00
Tommy Chow
779572a864 replace redundant confirmation dialogs with inline actions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:58:34 -08:00
Tommy Chow
02f4cd417b audit and optimize haptic feedback across the app
Add selectionClick to settings switches, discrete slider steps, bottom
nav tabs, and expandable section headers. Fix intensity levels for cache
clear and random color (medium → light). Add lightImpact to thumbnail
long-press. Fix inconsistent search result long-press level. Replace
blanket lightImpact in updateNotification with targeted haptics at
error/warning call sites to eliminate double-firing and match severity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:46:24 -08:00
Tommy Chow
21cb79d2c2 move menu styling to theme for app-wide consistency
Add menuTheme and menuButtonTheme to ThemeData matching the existing
overlay surface style (12px radius, outline border, compact density).
Remove per-use styling from chat_tabs.dart.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:30:02 -08:00
Tommy Chow
4c696745b0 add tab disconnect option via context menu
X button on secondary tabs now opens a MenuAnchor popup with
Disconnect (deactivates without removing) and Remove options.
Menu styled to match app overlay surfaces (12px radius, outline border).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:25:56 -08:00
Tommy Chow
89f8859ba3 add merged chat mode for multi-tab view
Merge only already-activated tabs; lazy tabs join when tapped. Dedup
messages by IRC id for Twitch shared chat overlap. Preserve draft and
reply state when switching tabs in merged mode. Merge button appears
only when 2+ tabs are loaded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:00:03 -08:00
Tommy Chow
fd4da6a393 replace get-twitch-token bash script with cross-platform Dart script
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:59:31 -08:00
Tommy Chow
26d26a9554 update plugin settings and add testing section to AGENTS.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:32:04 -08:00
Tommy Chow
ed59896c5f deduplicate SystemChrome calls and optimize PiP animation rebuilds
- extract _scheduleSystemUiModeSync to avoid redundant platform calls on
  every build, deferred via addPostFrameCallback with state tracking
- use AnimatedBuilder.child so the video subtree is not rebuilt on every
  animation tick during PiP spring-back
- move orientation reset from build() to initState() in home screen
- reformat landscape LayoutBuilder for consistency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:13:08 -08:00
Tommy Chow
d28a3279e4 replace per-message Future.delayed with lightweight callback queue
drains delayed chat/7TV callbacks via the existing 200ms message buffer
timer instead of creating one Future.delayed per incoming event, removing
the biggest memory-pressure risk on busy channels with chat delay active

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:11:42 -08:00
Tommy Chow
9e81b2d80a make auto-synced chat delay session-only and improve video store
- split chatDelay into persisted (manual) and session-only (synced) fields
  so auto-synced latency values don't carry over between channels
- make latency tracker singleton/idempotent to prevent overlapping JS pollers
- add isCycling guard to prevent re-entrant latency cycles
- deduplicate concurrent stream info API requests
- tie JS cleanup timer to video mode only (skip in chat-only)
- update UI to reflect effective delay instead of persisted value

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:11:21 -08:00
Tommy Chow
04e5479acd add keep screen awake setting
adds a user-controlled toggle (default on) under video settings that
controls whether wakelock is enabled during channel sessions. previously
wakelock was unconditional with no way to turn it off.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:58:32 -08:00
Tommy Chow
aad6b50d81 make token script cross-platform (macOS, Linux, Windows)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:19:40 -08:00
Tommy Chow
3b3cd92026 show buy me a coffee icon on iOS
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:45:24 -08:00
Tommy Chow
4ff331ec98 replace about dialog and FAQ tile with bottom-pinned author and version footer
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:31:32 -08:00
Tommy Chow
d500119219 move clear recent emotes action to tab bar icon button
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:40:33 -08:00
Tommy Chow
93c7b5b637 remove landscape top-edge gesture barrier causing black bar on iPad
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 22:28:48 -08:00
Tommy Chow
09b3b3543b add .env-based config, token script, and VS Code launch configs
Move Twitch credentials from hardcoded launch.json args to a gitignored
.env file read via --dart-define-from-file. Add a shell script to get a
user auth token through the Authorization Code flow for easy emulator
login. Update README with the new setup steps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 20:09:39 -08:00
Tommy Chow
6b765e4246 update Firebase iOS pods from 12.6.0 to 12.8.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 19:16:13 -08:00
Tommy Chow
2e9d0bd16a extract kCardBorderRadius constant for card thumbnails
Replace 13 occurrences of BorderRadius.all(Radius.circular(8)) with a
shared kCardBorderRadius constant in constants.dart. All uses represent
the same design token: card/thumbnail corner radius across stream cards,
category cards, and their skeleton loader placeholders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 19:01:58 -08:00
Tommy Chow
96bce87a0e add FrostyColors ThemeExtension, align hardcoded colors with M3 roles
Introduce FrostyColors ThemeExtension with 7 semantic color fields
(highlightedMessage, emoteOnly, followersOnly, slowMode, subOnly,
uniqueMode, overlayOnSurface) and brightness-aware light/dark variants.

Replace hardcoded colors across 15 files:
- Links use colorScheme.primary instead of Colors.blue
- Mentions use colorScheme.error instead of Colors.red
- First-time chatters share highlightedMessage (Twitch purple)
- Chat mode indicators use frostyColors fields
- Overlay text uses frostyColors.overlayOnSurface

Remove Provider<FrostyThemes> pattern from main.dart, channel.dart,
video_overlay.dart, and frosty_photo_view_dialog.dart.

Replace manual onSurface.withValues(alpha: 0.6) with M3's built-in
onSurfaceVariant for secondary text in section headers, chat modes,
alerts, loading indicators, and input hints. Fix double-muted
onSurfaceVariant.withValues(alpha: 0.6) in navigation icons and
notification dismiss button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 19:01:44 -08:00
Tommy Chow
59f5d2cb81 enlarge mobile carousel phones and tighten description spacing
Increase mobile phone frame from 192×440 to 240×520 (max-w-60,
max-h-130) with paired aspect-ratio constraints. Widen carousel
step to 260 to maintain gap between slides. Remove py-3 from
phone media to eliminate black letterboxing. Cap track height to
match phone so description stays close via justify-center.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 02:50:21 -08:00
Tommy Chow
10e924b4ab gate emote entrance animations on image load and optimize fetching
Emotes now animate (fade + deblur) only after each image loads from
cdn.7tv.app, with stagger based on load order. Also reduces network
requests by lazily assigning src to only the active particle count,
adds preconnect for the CDN, and marks emotes with decoding=async
and fetchpriority=low.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 02:23:42 -08:00
Tommy Chow
3a50e06c63 add responsive emote scaling with 118 7TV emotes, mobile support, and motion entrance
- expand emote list from 36 to 118 (xQc channel + 7TV global + trending)
- scale particle count dynamically from 12 (mobile) to 100 (4K) via container diagonal
- scale emote size (28–44px), opacity (0.25–0.35), and repulse radius per breakpoint
- enable on mobile with touch repulsion (was desktop-only)
- shuffle emotes per visit via Fisher-Yates in useEffect
- replace motion.img with plain img + motion animate() for blur+fade entrance
- move opacity to container layer for single-point control and WCAG AA safety

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 01:47:39 -08:00
Tommy Chow
1902761945 keep github text visible on mobile header and replace deprecated icon 2026-02-19 00:51:19 -08:00
Tommy Chow
a689991a43 make header and footer flow in layout, fix mobile viewport scroll
- header/footer are now flex children instead of absolute overlays
- mobile grid uses auto+1fr rows so intro sizes to content
- h-dvh + overflow-hidden + overscroll-none prevents mobile scroll bounce
- add horizontal padding to intro section

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 23:18:14 -08:00
Tommy Chow
246e1314aa remove redundant color-scheme style from html
next-themes enableColorScheme (default true) already sets this
dynamically to match the active theme.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 23:31:48 -08:00
Tommy Chow
374cb23111 update wrangler config: enable source maps and keep variable names 2026-02-17 23:11:47 -08:00
Tommy Chow
a2e9701434 add carousel accessibility: ARIA semantics, keyboard nav, reduced motion
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:00:31 -08:00
Tommy Chow
f209d73352 inset carousel prev/next buttons on landing page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 21:38:21 -08:00
Tommy Chow
862839e44a rename web worker config to frosty-web 2026-02-17 20:31:56 -08:00
Tommy Chow
62d698a99b fix worker self-reference to match cloudflare project name 2026-02-17 20:11:02 -08:00
Tommy Chow
f19da4151c
migrate web to next 16 template and redesign landing page (#526)
* migrate web to next-template stack (next 16, react 19, tailwind 4)

replace all infrastructure with next-template conventions:
- next.js 14 -> 16 (turbopack default, typed routes, react compiler)
- react 18 -> 19
- tailwind css 3 -> 4 (css-first config, oklch theme system)
- eslint 8 (.eslintrc) -> 9 (flat config with stricter rules)
- add shadcn/ui, base-ui, cva component primitives
- upgrade theme toggle to light/dark/system dropdown
- migrate hardcoded neutral classes to semantic oklch tokens
- drop unused @tailwindcss/forms, autoprefixer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* add no-misused-promises config for async JSX handlers, warn on array index keys

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* base-vega

* sync frosty web with next-template runtime and claude tooling for cloudflare parity

* gitignore .dev.vars per cloudflare best practice

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* add observability configuration to wrangler.jsonc

* remove marquee emote background from hero section

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* improve CLAUDE.md and AGENTS.md files across project

Remove redundant root CLAUDE.md (kept in .claude/ instead), add Commands
and Source Structure sections to Flutter AGENTS.md, clarify global vs
feature store placement, and fill gaps in web AGENTS.md (shadcn base-vega
style, constants.ts, no-array-index-key rule, commit conventions).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* switch shadcn theme from neutral to violet, replace twitch-purple with primary token

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* initial redesign

* auto-infer nativeButton in Button wrapper when render prop is used

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* replace skeleton carousel with real screenshots and video, add responsive phone scaling and mobile menu

- Replace PhoneSkeleton with PhoneFrame/PhoneMedia using actual app screenshots and video
- Fluid phone sizing via aspect-ratio with responsive max-h/max-w caps
- Non-circular carousel with swipe gestures, dot indicator, and hover opacity boost
- Video plays only when focused, pauses when off-screen
- Collapse header actions into dropdown menu on mobile, add Contact link
- Viewport-height layout with no scrolling on any screen size
- Remove footer, move contact to header

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* improve header consistency, carousel layout, and responsive centering

- add icons to all header buttons (Contact, Donate, GitHub)
- switch theme toggle to ghost variant for consistency
- bump header collapse breakpoint from md to lg
- move carousel arrows to overlay positions with animated enter/exit
- hide arrows at first/last slide
- fix vertical centering of phone+description group across all screen sizes
- add smooth blur+slide transition for description text

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* add contact dropdown with copy email toast

- replace direct mailto link with dropdown: send email or copy email
- install sonner for toast notifications, show top-center on copy
- extract emailAddress constant for clipboard use
- add copy option to mobile hamburger menu too

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* replace mobile hamburger menu with icon buttons

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* redesign hero copy, badge-style download buttons, and mobile layout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* add consistent padding and object-contain to carousel media

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* add emote physics background to hero section

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* move container styling to hero panel, refresh emote set with 2025 trending picks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* add staggered entrance animations and remove grid gap

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* add 30-day cache headers for video and logo assets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* add emote collisions and aspect-ratio tracking, refine hero layout

Emote physics: add particle-particle elastic collisions, per-particle
width measurement for correct aspect ratios, new emotes (aga, WW, o7,
FLASHBANG, LULW, Deadass), bump count to 30. Layout: absolute-position
header with z-20, increase mobile carousel ratio to 2fr/4fr, reserve
min-h-10 for description text, remove panel rounding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix carousel lint warnings: useSyncExternalStore, ref naming, stable keys

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* add seo metadata, sitemap, and robots.txt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* add privacy link to header and footer credit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* update privacy policy content and restyle with mono layout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* reorder header nav and add mobile hamburger menu

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refresh web dependencies and smooth footer credit hover transition

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 19:53:43 -08:00
Tommy Chow
9119a69b17 fix 7TV emote mutations on computed map, fix search store disposer leak, fix typo
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 20:34:49 -08:00
Tommy Chow
f9302b0d82 replace BackdropFilter with solid container on stream card badge, remove AnimatedContainer from BlurredContainer
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 20:13:35 -08:00
Tommy Chow
06baaafe26 bump agp 8.11.1, kotlin 2.2.20, gradle 8.14 to match flutter 3.38.9 defaults
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 20:13:35 -08:00
Tommy Chow
467dca9b86 upgrade dependencies, fix null-aware element lint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 20:13:35 -08:00
Tommy Chow
bc0726aaa6 update ios config: align deployment target, add privacy manifest
- align IPHONEOS_DEPLOYMENT_TARGET from 13.0 to 15.0 across all build
  configurations to match Podfile platform version
- update AppFrameworkInfo.plist MinimumOSVersion from 13.0 to 15.0
- add PrivacyInfo.xcprivacy for App Store compliance (required since May 2024)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 20:13:35 -08:00
Tommy Chow
68a6c57cf2 update android config: migrate gradle to kts, upgrade firebase plugins, fix edge-to-edge
- migrate settings.gradle, build.gradle, app/build.gradle to kotlin dsl (.kts)
- upgrade java/kotlin compatibility from 1.8 to 17
- upgrade firebase gradle plugins (google-services 4.4.4, crashlytics 3.0.6, perf 2.0.2)
- remove jetifier, add gradle caching and parallel builds
- fix windowDrawsSystemBarBackgrounds for edge-to-edge at targetSdk 36
- add dataExtractionRules for android 12+ backup compliance
- use layout.buildDirectory api (replaces deprecated buildDir)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 20:13:35 -08:00
237 changed files with 28798 additions and 7243 deletions

View file

@ -2,4 +2,4 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
@AGENTS.md
@../AGENTS.md

View file

@ -1,13 +0,0 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true,
"feature-dev@claude-plugins-official": true,
"commit-commands@claude-plugins-official": true,
"pr-review-toolkit@claude-plugins-official": true,
"code-simplifier@claude-plugins-official": true,
"typescript-lsp@claude-plugins-official": true,
"claude-md-management@claude-plugins-official": true,
"claude-code-setup@claude-plugins-official": true
},
"outputStyle": "Explanatory"
}

12
.cursor/mcp.json Normal file
View file

@ -0,0 +1,12 @@
{
"mcpServers": {
"dart": {
"command": "dart",
"args": ["mcp-server", "--force-roots-fallback"]
},
"next-devtools": {
"command": "node",
"args": ["web/node_modules/next-devtools-mcp/dist/index.js"]
}
}
}

2
.env.example Normal file
View file

@ -0,0 +1,2 @@
CLIENT_ID=
SECRET=

View file

@ -0,0 +1,14 @@
# Temporary probe: does this repo schedule Actions at all?
name: Actions probe
on:
push:
branches: [main]
workflow_dispatch:
jobs:
probe:
runs-on: desktop-release
steps:
- name: ping
run: echo "frosty actions probe ok"

View file

@ -0,0 +1,304 @@
# Builds a signed Frosty release APK and publishes a Forgejo release for Obtainium.
# Pattern: finamp-fork / jellyfin-android-fork on host runner `desktop-release`.
#
# Required repository secrets:
# RELEASE_KEYSTORE_BASE64, STORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD
# CLIENT_ID, SECRET (Twitch Helix app credentials — --dart-define)
#
# Obtainium:
# Source: Forgejo
# URL: https://forgejo.fsociety00.cc/api/v1/repos/copilot/frosty/releases
# APK filter: frosty-arm64-v8a-v.*\.apk
# Trim version: v?([0-9]+\.[0-9]+\.[0-9]+)
# Match group: 1
# Private repo → Forgejo API token in Obtainium.
name: Release APK
on:
push:
tags:
- 'v*'
workflow_dispatch:
permissions:
contents: write
concurrency:
group: frosty-release
cancel-in-progress: true
jobs:
build-and-release:
name: Build & publish release
runs-on: desktop-release
steps:
- name: Clone repository
shell: bash
env:
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
rm -rf .git
git init .
origin_url="${GITHUB_SERVER_URL#https://}"
origin_url="${origin_url#http://}"
git remote add origin "https://copilot:${FORGEJO_TOKEN}@${origin_url}/${GITHUB_REPOSITORY}.git"
git -c http.sslVerify=false fetch --depth=1 origin "${GITHUB_SHA}"
git checkout --force FETCH_HEAD
test -f pubspec.yaml
test -f android/app/build.gradle.kts
echo "Checked out ${GITHUB_REPOSITORY}@${GITHUB_SHA}"
- name: Set up JDK
shell: bash
run: |
set -euo pipefail
pkg_install() {
if [ "$(id -u)" -eq 0 ]; then "$@"
elif command -v sudo >/dev/null 2>&1; then sudo "$@"
else "$@"; fi
}
if ! command -v java >/dev/null 2>&1; then
if command -v dnf >/dev/null 2>&1; then pkg_install dnf install -y java-21-openjdk-devel
elif command -v apt-get >/dev/null 2>&1; then
pkg_install apt-get update
pkg_install apt-get install -y openjdk-21-jdk
elif command -v apk >/dev/null 2>&1; then pkg_install apk add --no-cache openjdk21
fi
fi
java -version
if command -v javac >/dev/null 2>&1; then
java_home="$(dirname "$(dirname "$(readlink -f "$(command -v javac)")")")"
echo "JAVA_HOME=$java_home" >> "$GITHUB_ENV"
fi
- name: Set up Android SDK
shell: bash
run: |
set -euo pipefail
sdk_candidates=(
"${ANDROID_HOME:-}"
"${ANDROID_SDK_ROOT:-}"
"/home/yuna/Android/Sdk"
"${HOME}/Android/Sdk"
"/opt/android-sdk-linux"
"/root/Android/Sdk"
)
sdk_dir=""
for candidate in "${sdk_candidates[@]}"; do
if [ -n "$candidate" ] && [ -d "$candidate/platforms" ] && [ -d "$candidate/build-tools" ]; then
sdk_dir="$candidate"
break
fi
done
if [ -z "$sdk_dir" ]; then
echo "Android SDK not found on desktop-release host."
exit 1
fi
echo "ANDROID_HOME=$sdk_dir" >> "$GITHUB_ENV"
echo "ANDROID_SDK_ROOT=$sdk_dir" >> "$GITHUB_ENV"
printf 'sdk.dir=%s\n' "$sdk_dir" > android/local.properties
echo "Using Android SDK: $sdk_dir"
- name: Set up Flutter
shell: bash
run: |
set -euo pipefail
pkg_install() {
if [ "$(id -u)" -eq 0 ]; then "$@"
elif command -v sudo >/dev/null 2>&1; then sudo "$@"
else "$@"; fi
}
if ! command -v curl >/dev/null 2>&1; then
if command -v apk >/dev/null 2>&1; then pkg_install apk add --no-cache curl bash git unzip xz
elif command -v dnf >/dev/null 2>&1; then pkg_install dnf install -y curl git unzip
elif command -v apt-get >/dev/null 2>&1; then
pkg_install apt-get update
pkg_install apt-get install -y curl git unzip
fi
fi
# Prefer pinned SDK already on the desktop-release host.
flutter_version="$(awk '/^[[:space:]]*flutter:/{print $2; exit}' pubspec.yaml)"
flutter_version="${flutter_version:-3.47.0}"
candidates=(
"/home/yuna/.local/flutter-${flutter_version}"
"${HOME}/.cache/frosty-ci-flutter-${flutter_version}"
"/home/yuna/.local/flutter"
)
flutter_home=""
for c in "${candidates[@]}"; do
if [ -x "${c}/bin/flutter" ]; then
flutter_home="$c"
break
fi
done
if [ -z "$flutter_home" ]; then
flutter_home="${HOME}/.cache/frosty-ci-flutter-${flutter_version}"
rm -rf "${flutter_home}"
git clone --depth 1 --branch "${flutter_version}" https://github.com/flutter/flutter.git "${flutter_home}"
fi
echo "${flutter_home}/bin" >> "$GITHUB_PATH"
echo "PATH=${flutter_home}/bin:${PATH}" >> "$GITHUB_ENV"
export PATH="${flutter_home}/bin:${PATH}"
flutter --version
flutter precache --android
- name: Decode keystore
shell: bash
env:
RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: |
set -euo pipefail
if [ -z "${RELEASE_KEYSTORE_BASE64:-}" ] || [ -z "${STORE_PASSWORD:-}" ] || [ -z "${KEY_ALIAS:-}" ] || [ -z "${KEY_PASSWORD:-}" ]; then
echo "Missing signing secrets: RELEASE_KEYSTORE_BASE64, STORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD"
exit 1
fi
printf '%s' "$RELEASE_KEYSTORE_BASE64" | base64 -d > android/upload-keystore.jks
test -s android/upload-keystore.jks
{
echo "storePassword=${STORE_PASSWORD}"
echo "keyPassword=${KEY_PASSWORD}"
echo "keyAlias=${KEY_ALIAS}"
echo "storeFile=${GITHUB_WORKSPACE}/android/upload-keystore.jks"
} > android/key.properties
echo "Keystore ready ($(wc -c < android/upload-keystore.jks) bytes)."
- name: Resolve version metadata
id: version
shell: bash
run: |
set -euo pipefail
raw="$(grep -E '^version:' pubspec.yaml | head -1 | awk '{print $2}')"
VERSION_NAME="${raw%%+*}"
VERSION_CODE="${raw##*+}"
[ "$VERSION_CODE" != "$raw" ] || VERSION_CODE=1
SHORT_SHA="${GITHUB_SHA::7}"
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
TAG="${GITHUB_REF#refs/tags/}"
else
TAG="build-${VERSION_NAME}-${VERSION_CODE}-${SHORT_SHA}"
fi
echo "version_name=${VERSION_NAME}" >> "$GITHUB_OUTPUT"
echo "version_code=${VERSION_CODE}" >> "$GITHUB_OUTPUT"
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG} version=${VERSION_NAME}+${VERSION_CODE}"
- name: Build signed release APK
id: build
shell: bash
env:
CLIENT_ID: ${{ secrets.CLIENT_ID }}
SECRET: ${{ secrets.SECRET }}
VERSION_NAME: ${{ steps.version.outputs.version_name }}
VERSION_CODE: ${{ steps.version.outputs.version_code }}
run: |
set -euo pipefail
if [ -z "${CLIENT_ID:-}" ] || [ -z "${SECRET:-}" ]; then
echo "Missing Twitch secrets CLIENT_ID / SECRET (Helix OAuth app)."
exit 1
fi
flutter pub get
flutter build apk --release \
--build-name="${VERSION_NAME}" \
--build-number="${VERSION_CODE}" \
--dart-define="CLIENT_ID=${CLIENT_ID}" \
--dart-define="SECRET=${SECRET}"
mkdir -p dist
cp build/app/outputs/flutter-apk/app-release.apk \
"dist/frosty-v${VERSION_NAME}.apk"
flutter build apk --release --split-per-abi \
--build-name="${VERSION_NAME}" \
--build-number="${VERSION_CODE}" \
--dart-define="CLIENT_ID=${CLIENT_ID}" \
--dart-define="SECRET=${SECRET}" || true
for abi in arm64-v8a armeabi-v7a x86_64; do
src="build/app/outputs/flutter-apk/app-${abi}-release.apk"
if [ -f "$src" ]; then
cp "$src" "dist/frosty-${abi}-v${VERSION_NAME}.apk"
fi
done
ls -lh dist/
- name: Publish Forgejo release
shell: bash
env:
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.version.outputs.tag }}
VERSION_NAME: ${{ steps.version.outputs.version_name }}
VERSION_CODE: ${{ steps.version.outputs.version_code }}
SHORT_SHA: ${{ steps.version.outputs.short_sha }}
run: |
set -euo pipefail
pkg_install() {
if [ "$(id -u)" -eq 0 ]; then "$@"
elif command -v sudo >/dev/null 2>&1; then sudo "$@"
else return 1; fi
}
for pkg in curl jq; do
command -v "$pkg" >/dev/null 2>&1 && continue
if command -v apk >/dev/null 2>&1; then pkg_install apk add --no-cache "$pkg"
elif command -v dnf >/dev/null 2>&1; then pkg_install dnf install -y "$pkg"
elif command -v apt-get >/dev/null 2>&1; then
pkg_install apt-get update
pkg_install apt-get install -y "$pkg"
fi
done
API="${GITHUB_API_URL:-${GITHUB_SERVER_URL}/api/v1}/repos/${GITHUB_REPOSITORY}"
AUTH="Authorization: token ${FORGEJO_TOKEN}"
BODY_FILE="$(mktemp)"
cat > "${BODY_FILE}" <<BODY_EOF
Frosty fork (Twitch client) — native player + TwitchAdSolutions adblock.
| | |
|---|---|
| Version | ${VERSION_NAME} (${VERSION_CODE}) |
| Commit | ${SHORT_SHA} |
| Package | \`com.tommychow.frosty\` |
**Obtainium:** Forgejo source → \`…/copilot/frosty/releases\`, APK filter \`frosty-arm64-v8a-v.*\\.apk\`, trim \`v?([0-9]+\\.[0-9]+\\.[0-9]+)\` group \`1\`.
Stock Play Frosty uses a different signing key — uninstall it before installing this fork, or Obtainium cannot update in place.
BODY_EOF
# Create or update release for TAG
existing="$(curl -sS -H "$AUTH" "${API}/releases/tags/${TAG}" || true)"
release_id="$(printf '%s' "$existing" | jq -r '.id // empty')"
if [ -z "$release_id" ]; then
payload="$(jq -n \
--arg tag "$TAG" \
--arg name "Frosty ${VERSION_NAME} (${SHORT_SHA})" \
--rawfile body "$BODY_FILE" \
'{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:false}')"
resp="$(curl -sS -X POST -H "$AUTH" -H 'Content-Type: application/json' \
-d "$payload" "${API}/releases")"
release_id="$(printf '%s' "$resp" | jq -r '.id')"
else
payload="$(jq -n \
--arg name "Frosty ${VERSION_NAME} (${SHORT_SHA})" \
--rawfile body "$BODY_FILE" \
'{name:$name, body:$body, draft:false, prerelease:false}')"
curl -sS -X PATCH -H "$AUTH" -H 'Content-Type: application/json' \
-d "$payload" "${API}/releases/${release_id}" >/dev/null
fi
test -n "$release_id" && test "$release_id" != "null"
for apk in dist/*.apk; do
[ -f "$apk" ] || continue
name="$(basename "$apk")"
echo "Uploading ${name}…"
curl -sS -X POST -H "$AUTH" \
-H "Content-Type: application/octet-stream" \
--data-binary @"${apk}" \
"${API}/releases/${release_id}/assets?name=${name}" \
| jq -r '.name // .message'
done
echo "Published ${TAG} as release ${release_id}"
rm -f "$BODY_FILE"

View file

@ -3,18 +3,57 @@ on:
push:
branches: [main]
jobs:
test:
name: Check and test on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
# Linux runner: analyze + test once (both are OS-independent) and compile a
# release APK to catch release-only breakage. Debug-signed via the build.gradle
# fallback (ALLOW_DEBUG_SIGNED_RELEASE) — no signing secrets needed.
android:
name: Analyze, test, and build Android
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
# Match the JDK the release workflows build with, so this compile check
# faithfully catches what would break a real release.
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
- uses: subosito/flutter-action@v2
with:
flutter-version-file: pubspec.yaml
cache: true
- run: flutter doctor -v
- run: flutter pub get
# Fail if committed generated files (*.g.dart) are stale — CI never runs
# build_runner otherwise, so forgetting to regenerate after editing a MobX
# store or @JsonSerializable model slips past analyze/test/build.
- run: dart run build_runner build --delete-conflicting-outputs
- name: Verify generated files are up to date
run: |
if [ -n "$(git status --porcelain)" ]; then
echo '::error::Generated files are out of date. Run `dart run build_runner build --delete-conflicting-outputs` and commit the result.'
git status --porcelain
git diff
exit 1
fi
- run: flutter analyze
- run: flutter test
- run: flutter build apk --release
env:
ALLOW_DEBUG_SIGNED_RELEASE: '1'
# macOS runner: release-compile iOS (no codesign, no secrets) — the only thing
# that must run on macOS. analyze/test already covered on the Linux job.
ios:
name: Build iOS
runs-on: macos-latest
steps:
- uses: actions/checkout@v6
# Match release workflows and avoid GitHub's lagging default Xcode.
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- uses: subosito/flutter-action@v2
with:
flutter-version-file: pubspec.yaml
cache: true
- run: flutter pub get
- run: flutter build ios --release --no-codesign

View file

@ -32,6 +32,7 @@ jobs:
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version-file: pubspec.yaml
cache: true
# Decode and create the upload-keystore.jks file, used for signing the app.
@ -96,7 +97,7 @@ jobs:
# Create a draft release on GitHub.
- name: Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
draft: true
generate_release_notes: ${{ steps.release_notes.outputs.found != 'true' }}

View file

@ -8,10 +8,36 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
# Match the JDK the release workflows build with, so the release-mode APK
# check below faithfully catches what would break a real release.
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
- uses: subosito/flutter-action@v2
with:
flutter-version-file: pubspec.yaml
cache: true
- run: flutter doctor -v
- run: flutter pub get
# Fail if committed generated files (*.g.dart) are stale — CI never runs
# build_runner otherwise, so forgetting to regenerate after editing a MobX
# store or @JsonSerializable model slips past analyze/test/build.
- run: dart run build_runner build --delete-conflicting-outputs
- name: Verify generated files are up to date
run: |
if [ -n "$(git status --porcelain)" ]; then
echo '::error::Generated files are out of date. Run `dart run build_runner build --delete-conflicting-outputs` and commit the result.'
git status --porcelain
git diff
exit 1
fi
- run: flutter analyze
- run: flutter test
# Release-mode compile check (no signing secrets needed — Android falls back
# to debug signing via ALLOW_DEBUG_SIGNED_RELEASE). Catches release-only
# breakage that debug builds and tests miss (AOT compile, icon tree-shaking,
# plugin/Gradle release incompatibility).
- run: flutter build apk --release
env:
ALLOW_DEBUG_SIGNED_RELEASE: '1'

126
.github/workflows/release-dryrun.yml vendored Normal file
View file

@ -0,0 +1,126 @@
# Dry-runs the full store release pipeline without publishing, to catch
# credential, signing, dependency, and store-validation breakage before an
# actual release. Mirrors store-release.yml but stops short of publishing:
# Android validates the AAB against Google Play (validate_only); iOS builds a
# fully signed archive via match (readonly) and skips the App Store upload.
name: Release dry-run
on:
# Run on demand before cutting a release.
workflow_dispatch:
# Monthly heartbeat so cert expiry, secret rotation, and dependency drift
# surface on their own between releases.
schedule:
- cron: '0 9 1 * *' # 1st of the month, 09:00 UTC
# Setup environment variables for use in all jobs.
env:
# Gemfile path used for caching dependencies.
BUNDLE_GEMFILE: ${{ github.workspace }}/android/Gemfile
CLIENT_ID: ${{ secrets.CLIENT_ID }}
SECRET: ${{ secrets.SECRET }}
LC_ALL: en_US.UTF-8
LANG: en_US.UTF-8
jobs:
android:
name: Dry-run Android release
runs-on: ubuntu-latest
environment: release-android
env:
UPLOAD_KEYSTORE: ${{ secrets.UPLOAD_KEYSTORE }}
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
KEY_FILE_PATH: android/key.properties
PLAY_STORE_CREDENTIALS: ${{ secrets.PLAY_STORE_CREDENTIALS }}
steps:
- name: Checkout Frosty repo
uses: actions/checkout@v6
# Install Ruby and install/cache dependencies with bundler in order to use fastlane.
- name: Set up Ruby and Bundler
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.3
bundler-cache: true
# Setup Java so that we can build the appbundle.
- name: Set up Java
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
# Setup and cache Flutter to install packages and build.
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version-file: pubspec.yaml
cache: true
# Decode and create the upload-keystore.jks file, used for signing the app.
- name: Decode and create upload-keystore.jks
run: echo $UPLOAD_KEYSTORE | base64 --decode > $GITHUB_WORKSPACE/android/upload-keystore.jks
# Create the key.properties file, used for signing the app.
- name: Create key.properties
run: |
echo storePassword=$STORE_PASSWORD >> $KEY_FILE_PATH
echo keyPassword=$KEY_PASSWORD >> $KEY_FILE_PATH
echo keyAlias=upload >> $KEY_FILE_PATH
echo storeFile=$GITHUB_WORKSPACE/android/upload-keystore.jks >> $KEY_FILE_PATH
# Build the Android app bundle (AAB) and pass the environment variables.
#
# The version code is overridden because Play rejects one it has already
# seen, and pubspec's code is by definition already published between
# releases — so the dry-run would fail on every scheduled run and never
# reach the validation it exists to perform. Epoch seconds is always
# unused, always increasing, and well under Play's 2100000000 cap. The
# bundle is only ever validated, never published, so the value is thrown
# away. versionName still comes from pubspec.
- name: Build the Android AAB
run: flutter build appbundle --build-number=$(date +%s) --dart-define CLIENT_ID=$CLIENT_ID --dart-define SECRET=$SECRET
# Validate the AAB and credentials against Google Play without publishing.
- name: Validate release with fastlane (no publish)
run: cd android && bundle exec fastlane deploy dry_run:true
ios:
name: Dry-run iOS release
runs-on: macos-latest
environment: release-ios
env:
APP_STORE_KEY_ID: ${{ secrets.APP_STORE_KEY_ID }}
APP_STORE_ISSUER_ID: ${{ secrets.APP_STORE_ISSUER_ID }}
APP_STORE_CREDENTIALS: ${{ secrets.APP_STORE_CREDENTIALS }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
steps:
- name: Checkout Frosty repo
uses: actions/checkout@v6
# Always pick the newest production Xcode on the runner.
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
# Install Ruby and install/cache dependencies with bundler in order to use fastlane.
- name: Set up Ruby and Bundler
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.3
bundler-cache: true
# Setup and cache Flutter to install packages and build.
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version-file: pubspec.yaml
cache: true
# Setup the iOS project with environment variables without signing (building and signing will happen in fastlane).
- name: Config the iOS build
run: flutter build ios --no-codesign --config-only --dart-define CLIENT_ID=$CLIENT_ID --dart-define SECRET=$SECRET
# Authenticate ASC API key, resolve signing via match (readonly), and build a
# fully signed archive without uploading to the App Store.
- name: Sign and build with fastlane (no publish)
run: cd ios && bundle exec fastlane release dry_run:true

View file

@ -35,7 +35,7 @@ jobs:
- name: Set up Ruby and Bundler
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.2
ruby-version: 3.3
bundler-cache: true
# Setup Java so that we can build the appbundle.
@ -49,6 +49,7 @@ jobs:
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version-file: pubspec.yaml
cache: true
# Decode and create the upload-keystore.jks file, used for signing the app.
@ -84,7 +85,7 @@ jobs:
- name: Checkout Frosty repo
uses: actions/checkout@v6
# Always pick the newest production Xcode on the runner
# Always pick the newest production Xcode on the runner.
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
@ -93,13 +94,14 @@ jobs:
- name: Set up Ruby and Bundler
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.2
ruby-version: 3.3
bundler-cache: true
# Setup and cache Flutter to install packages and build.
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version-file: pubspec.yaml
cache: true
# Setup the iOS project with environment variables without signing (building and signing will happen in fastlane).

7
.gitignore vendored
View file

@ -21,7 +21,7 @@ migrate_working_dir/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
.vscode/
# .vscode/
# Flutter/Dart/Pub related
**/doc/api/
@ -44,6 +44,8 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
# AGP 9 writes a problems report here even when the Gradle build dir is redirected
/android/build/
# Folder containing random testing files
random/
@ -64,5 +66,8 @@ random/
# potentially sensitive review information
**/fastlane/metadata/review_information
# secrets
.env
# ai
.claude/settings.local.json

12
.mcp.json Normal file
View file

@ -0,0 +1,12 @@
{
"mcpServers": {
"dart": {
"command": "dart",
"args": ["mcp-server", "--force-roots-fallback"]
},
"next-devtools": {
"command": "node",
"args": ["web/node_modules/next-devtools-mcp/dist/index.js"]
}
}
}

25
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,25 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "frosty",
"request": "launch",
"type": "dart",
"args": ["--dart-define-from-file=.env"]
},
{
"name": "frosty (profile mode)",
"request": "launch",
"type": "dart",
"flutterMode": "profile",
"args": ["--dart-define-from-file=.env"]
},
{
"name": "frosty (release mode)",
"request": "launch",
"type": "dart",
"flutterMode": "release",
"args": ["--dart-define-from-file=.env"]
}
]
}

View file

@ -1,27 +1,46 @@
# AGENTS.md
## Workflow
Flutter Twitch client for iOS and Android. MobX with `build_runner`. The landing page is `web/` (own `AGENTS.md`). Flutter is pinned in `pubspec.yaml` `environment.flutter` — bump that one field; CI reads it via `flutter-version-file`.
- In plan mode, interview thoroughly — ask about UI/UX, tradeoffs, and edge cases before coding
- When new code supersedes existing functionality, find and remove everything it makes redundant
- Favor parallel tool calls and subagents when tasks are independent
## Commands
## Architecture (non-obvious bits)
```bash
flutter pub get
flutter run --dart-define-from-file=.env # local; .env is gitignored
flutter run --dart-define=CLIENT_ID=ID --dart-define=SECRET=SECRET
flutter analyze
flutter test
flutter test test/path/to/file.dart
dart run build_runner build # after MobX / @JsonSerializable changes
```
- Screens live in `lib/screens/{feature}/` with co-located `stores/` subdirectories — don't create top-level store files
- All API services extend `BaseApiClient` (`lib/apis/base_api_client.dart`) — don't create standalone API classes
- MobX stores use a generated mixin pattern — see `lib/screens/settings/stores/auth_store.dart` for the canonical example
`--delete-conflicting-outputs` is ignored as of build_runner 2.15. Don't add it.
## Layout
- `lib/apis/` — API clients extend `BaseApiClient`
- `lib/models/` — data models; `@JsonSerializable` ones commit `.g.dart`
- `lib/screens/{feature}/` — screens; feature stores in co-located `stores/`
- `lib/stores/` — global stores only (`global_assets_store.dart`)
- `test/` mirrors `lib/`; shared fixtures in `test/fixtures/`
Don't add top-level store files or standalone API classes. Canonical MobX store: `lib/screens/settings/stores/auth_store.dart`. Extract state, reactions, and actions into stores when it keeps widgets on rendering.
New or changed logic (stores, API clients, parsing, utils) ships with tests; extend existing tests when touching tested code. Pure UI or config may skip. HTTP: `http_mock_adapter` (`DioAdapter`) with full URLs. Otherwise `mocktail`.
## Gotchas
- Run requires Twitch credentials: `flutter run --dart-define=clientId=YOUR_ID --dart-define=secret=YOUR_SECRET`
- After changing MobX stores or `@JsonSerializable` models, run `dart run build_runner build`. Never edit `.g.dart` files directly. Use `--delete-conflicting-outputs` if the build fails on stale generated files. Commit `.g.dart` files to source control.
- The secure storage cleanup in `main.dart` looks unnecessary but handles an Android/iOS edge case where secure storage persists after uninstall. Don't remove it.
- Use package imports (`import 'package:frosty/...'`), not relative imports
- Always include trailing commas
- Use single quotes
- Run `flutter analyze` after making changes. Run `flutter test` if changes touch testable logic.
- Commit regenerated `.g.dart` files. Never edit them. CI fails the PR if they're stale.
- `main.dart` secure-storage `deleteAll()` looks redundant; it handles Android/iOS storage surviving uninstall. Don't remove it.
- `flutter_secure_storage` stays on v9 (v10 shipped in 5.0.0 and was reverted in 5.0.1 over an Android 14 startup freeze). That also holds `package_info_plus` v9 and `device_info_plus` v12 (win32). Don't jump them in a drive-by upgrade.
- Git-pinned forks in `pubspec.yaml` (`extended_text_field`, `better_native_video_player`, `simple_pip_mode`) carry patches. Don't swap them back to pub.dev.
- `android/gradle.properties`: keep `android.builtInKotlin=false` and `android.newDsl=false` so plugins that still apply KGP build on AGP 9.
- `ios/Runner/AppDelegate.swift` is customized (`CookieExtractorPlugin` + `FlutterImplicitEngineDelegate`). Flutter will not auto-migrate UIScene; don't replace the delegate with the template.
- `android/.../MainActivity.kt` extends `PipCallbackHelperActivityWrapper` and registers `CookieExtractorPlugin` by hand. Don't revert it to `FlutterActivity`.
- Helix OAuth (`Bearer`) and the Twitch web GQL cookie are different tokens. Native playback uses the cookie; don't send the Helix token to GQL/Usher.
- `syncedChatDelay` is runtime-only (`includeFromJson/toJson: false`). Don't persist it with the other settings.
- Don't remove the `ALLOW_DEBUG_SIGNED_RELEASE` branch in `android/app/build.gradle.kts`. Real releases must still fail if `key.properties` is missing.
## Commits
## Releases
Lowercase, no prefixes (e.g., `fix landscape bottom padding in chat bottom bar`). Keep commits tightly scoped.
When cutting a store or GitHub release, read `docs/release.md` and follow it. That playbook is repo-local, not a global skill. `assets/release-notes.md` keeps about three consecutive versions; drop from the oldest end, never skip.

61
DESIGN.md Normal file
View file

@ -0,0 +1,61 @@
# Frosty fork — Twitch ad blocking (DESIGN)
Personal Forgejo fork of [tommyxchow/frosty](https://github.com/tommyxchow/frosty) that adds **TwitchAdSolutions (vaft)**-style live ad blocking.
Canonical living plan: session `active-plan.md` for conversation `37b527bf-e540-4753-be1f-39906fe2ba28`. ADR: **ADR-026**.
## Problem
Upstream Frosty plays Twitch HLS (native) or `player.twitch.tv` (WebView). Twitch may stitch ad pods into the media playlist (`stitched` / `EXT-X-DATERANGE`). Upstream only **detects** native ad breaks (to avoid bogus latency recovery); it does **not** block ads. Turbo/sub `hide_ads` via linked web session is separate and not a client blocker.
## Approach (vaft-equivalent)
Browser vaft hooks `Worker`/`fetch` and rewrites m3u8. Frosty’s native player cannot do that from Dart (`loadUrl` + headers only).
| Layer | Behavior |
| --- | --- |
| Primary token | GQL `PlaybackAccessToken` with `playerType=popout` (vaft `ForceAccessTokenPlayerType`) when Settings `blockTwitchAds` is on |
| Ad break | Native `adBreakStream` → probe backup types `embed` → `popout` → `autoplay`; accept first Usher multivariant whose sampled media playlist lacks `stitched`; `loadUrl` that URL |
| Ad end | Restore primary popout Usher URL |
| WebView | Best-effort inject of vendored `assets/js/vaft.user.js` (not true document-start on Android) |
| UI | Settings toggle (default on; enabling forces native player); overlay “Blocking ads” |
## Key code
- `lib/apis/twitch_ad_solutions.dart` — constants + `TwitchAdFreePlayback`
- `lib/apis/twitch_gql_api.dart` — `playerType` / `platform`, `fetchPlaylistText`
- `lib/screens/channel/video/native_video_store.dart` — load + swap
- `lib/screens/channel/video/video_store.dart` — WebView inject
- `lib/screens/settings/stores/settings_store.dart` — `blockTwitchAds`
- `assets/js/vaft.user.js` — upstream TwitchAdSolutions script
## Non-goals (v1)
- Upstream PR
- Full Worker prototype cleaning
- Third-party proxies
- HLS segment stripping inside `better_native_video_player` (unless smoke tests demand it)
## Tags + viewer sort (v5.2.2+)
Helix cannot filter by freeform tags or sort ascending. GQL covers both:
- Search: `SearchFreeformTags` → tap → `TagStreams` (`streams` + `freeformTags`)
- Sort: Top (asc), category (`game.streams`), tag — `VIEWER_COUNT` / `VIEWER_COUNT_ASC` via `StreamSortButton`
Key code: `lib/apis/twitch_gql_api.dart`, `lib/screens/home/search/`, `lib/widgets/stream_sort_button.dart`, `ListStore` / `ListType.tag`.
## Stream languages (v5.2.4+)
Settings → General → Stream languages (multi-select chips). Default `de`. Applied to Top, category, and tag lists.
- Helix `language=` when one language; multi-language / tags via Helix over-fetch + client filter
- Viewer sort is persisted (`streamViewerSort`)
- **Tag lists (asc + desc):** GQL `streams` with `freeformTags` + `sort` + cursor. Page-2+ uses Client-Integrity (same mint/retry as follow via `UserStore.runWithGqlIntegrity`). Helix + client tag filter only if integrity fails / no web session.
- **Follow / integrity:** Dio `POST /integrity` is almost always `is_bad_bot`. Follow retries via `IntegrityWebViewDialog` (capture `Client-Integrity` + `X-Device-Id` from Twitch's own WebView GQL traffic), then `UserStore.applyWebIntegrity`.
- **Top / category most viewers (desc):** Helix directory order (paginates)
- **Top / category fewest (asc):** GQL `VIEWER_COUNT_ASC` with the same integrity pagination; Helix fill + client re-sort as fallback
## Build
Flutter **3.47.0** (pinned in `pubspec.yaml`). Local SDK: `/home/yuna/.local/flutter-3.47.0`.

View file

@ -85,11 +85,17 @@ For a more detailed overview, visit [frostyapp.io](https://www.frostyapp.io/).
3. Go to the [Twitch dev console](https://dev.twitch.tv/login) and register a new application to retrieve a **Client ID** and **Client Secret**.
4. Use [`--dart-define`](https://dartcode.org/docs/using-dart-define-in-flutter/) to set the `clientId` and `secret` environment variables with your **Client ID** and **Client Secret**.
4. Copy `.env.example` to `.env` and fill in your credentials:
```bash
cp .env.example .env
```
5. Run `flutter pub get` to fetch all the dependencies.
6. Choose an emulator or device and run the app!
6. Choose an emulator or device and run the app! The included VS Code launch configurations will automatically read your `.env` file.
7. (Optional) Run `dart run scripts/get_twitch_token.dart` to quickly get a user auth token. Long-press the **Anonymous** account tile in the app to log in.
> [!IMPORTANT]
> Frosty uses [MobX](https://mobx.netlify.app/) for state management. Please refer to the documentation about code generation, otherwise your changes within MobX stores may not be applied.

View file

@ -16,6 +16,9 @@ analyzer:
- ios/**
- android/**
- web/**
- windows/**
- macos/**
- linux/**
linter:
# The lint rules applied to this project can be customized in the

View file

@ -3,13 +3,13 @@ GEM
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.8.8)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1200.0)
aws-sdk-core (3.240.0)
aws-partitions (1.1260.0)
aws-sdk-core (3.252.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@ -17,18 +17,19 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sdk-kms (1.129.0)
aws-sdk-core (~> 3, >= 3.248.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.209.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-s3 (1.225.1)
aws-sdk-core (~> 3, >= 3.248.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
bigdecimal (4.0.1)
base64 (0.3.0)
benchmark (0.5.0)
bigdecimal (4.1.2)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
@ -42,7 +43,7 @@ GEM
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.112.0)
faraday (1.10.4)
faraday (1.10.5)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
@ -67,19 +68,20 @@ GEM
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday-retry (1.0.4)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.230.0)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
fastimage (2.4.1)
fastlane (2.234.0)
CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
aws-sdk-s3 (~> 1.197)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2.0)
bundler (>= 1.12.0, < 3.0.0)
base64 (~> 0.2)
benchmark (>= 0.1.0)
bundler (>= 1.17.3, < 5.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
@ -90,11 +92,11 @@ GEM
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.0.0)
fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-env (>= 1.6.0, <= 2.1.1)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
@ -103,10 +105,11 @@ GEM
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3.0)
mutex_m (~> 0.3)
naturally (~> 2.2)
nkf (~> 0.2.0)
nkf (~> 0.2)
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
@ -119,41 +122,42 @@ GEM
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.3)
google-apis-androidpublisher_v3 (0.102.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0)
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.8.0)
google-apis-iamcredentials_v1 (0.27.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.63.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.9.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.5.0)
google-cloud-storage (1.47.0)
google-cloud-env (2.1.1)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.6.0)
google-cloud-storage (1.61.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.31.0)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
googleauth (~> 1.9)
mini_mime (~> 1.0)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
googleauth (1.11.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.1)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
@ -164,42 +168,41 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.18.0)
jwt (2.10.2)
json (2.19.9)
jwt (2.10.3)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.19.1)
multi_json (1.21.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
nkf (0.2.0)
nkf (0.3.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
public_suffix (7.0.0)
rake (13.3.1)
public_suffix (7.0.5)
rake (13.4.2)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
retriable (3.8.0)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.21.0)
signet (0.22.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)

View file

@ -1,60 +0,0 @@
plugins {
id "com.android.application"
// START: FlutterFire Configuration
id 'com.google.gms.google-services'
id 'com.google.firebase.firebase-perf'
id 'com.google.firebase.crashlytics'
// END: FlutterFire Configuration
id "kotlin-android"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin"
}
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
namespace = "com.tommychow.frosty"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8
}
defaultConfig {
applicationId = "com.tommychow.frosty"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
release {
keyAlias keystoreProperties["keyAlias"]
keyPassword keystoreProperties["keyPassword"]
storeFile keystoreProperties["storeFile"] ? file(keystoreProperties["storeFile"]) : null
storePassword keystoreProperties["storePassword"]
}
}
buildTypes {
release {
signingConfig signingConfigs.release
}
}
}
flutter {
source = "../.."
}

View file

@ -0,0 +1,70 @@
import java.io.FileInputStream
import java.util.Properties
plugins {
id("com.android.application")
id("com.google.gms.google-services")
id("com.google.firebase.firebase-perf")
id("com.google.firebase.crashlytics")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
namespace = "com.tommychow.frosty"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
applicationId = "com.tommychow.frosty"
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String?
keyPassword = keystoreProperties["keyPassword"] as String?
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
storePassword = keystoreProperties["storePassword"] as String?
}
}
buildTypes {
release {
// Use the real upload key when key.properties is present (release
// workflows create it from secrets). Allow debug signing only when a CI
// compile-only check explicitly opts in via ALLOW_DEBUG_SIGNED_RELEASE;
// otherwise fail loudly so a real release can never be silently
// debug-signed if the keystore step is ever missing.
signingConfig = when {
keystorePropertiesFile.exists() -> signingConfigs.getByName("release")
System.getenv("ALLOW_DEBUG_SIGNED_RELEASE") != null -> signingConfigs.getByName("debug")
else -> error("key.properties missing — set ALLOW_DEBUG_SIGNED_RELEASE=1 for compile-only builds")
}
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}

View file

@ -13,7 +13,8 @@
android:label="Frosty"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:allowBackup="false">
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules">
<activity
android:name=".MainActivity"
android:exported="true"

View file

@ -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)
}
}

View file

@ -1,6 +1,11 @@
package com.tommychow.frosty
import cl.puntito.simple_pip_mode.PipCallbackHelperActivityWrapper
import io.flutter.embedding.engine.FlutterEngine
class MainActivity: PipCallbackHelperActivityWrapper() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(CookieExtractorPlugin())
}
}

View file

@ -4,7 +4,7 @@
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.

View file

@ -7,7 +7,7 @@
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.

View file

@ -4,7 +4,7 @@
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.

View file

@ -7,7 +7,7 @@
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" path="."/>
</cloud-backup>
<device-transfer>
<exclude domain="root" path="."/>
</device-transfer>
</data-extraction-rules>

View file

@ -1,18 +0,0 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = "../build"
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

37
android/build.gradle.kts Normal file
View file

@ -0,0 +1,37 @@
import com.android.build.api.variant.LibraryAndroidComponentsExtension
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
// AGP 9 requires each library to compile against at least the compileSdk of
// its dependencies. Several Flutter plugins still pin compileSdk 33/34.
// finalizeDsl runs after the plugin's android {} block. Does not change minSdk.
subprojects {
pluginManager.withPlugin("com.android.library") {
extensions.configure<LibraryAndroidComponentsExtension>("androidComponents") {
finalizeDsl { it.compileSdk = 36 }
}
}
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View file

@ -17,11 +17,14 @@ default_platform(:android)
platform :android do
desc "Deploy a new version to the Google Play"
lane :deploy do
# Upload to the play store ussing the provided credentials and AAB from flutter build.
lane :deploy do |options|
# Upload to the Play Store using the provided credentials and AAB from flutter build.
# With dry_run:true (release dry-run workflow), validate_only authenticates and
# validates the AAB against Google Play without committing the release.
upload_to_play_store(
json_key_data: Base64.decode64(ENV["PLAY_STORE_CREDENTIALS"]),
aab: "../build/app/outputs/bundle/release/app-release.aab"
aab: "../build/app/outputs/bundle/release/app-release.aab",
validate_only: options[:dry_run] || false
)
end
end

View file

@ -1 +1 @@
Watch and chat on Twitch with your favorite emotes from 7TV, BTTV, and FFZ!
Watch live streams and see community emotes in chat.

View file

@ -1 +1 @@
Frosty for Twitch
Frosty

View file

@ -1,3 +1,8 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
org.gradle.caching=true
org.gradle.parallel=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false

View file

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip

View file

@ -1,28 +0,0 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.9.1" apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
id "com.google.gms.google-services" version "4.3.15" apply false
id "com.google.firebase.firebase-perf" version "1.4.2" apply false
id "com.google.firebase.crashlytics" version "2.9.9" apply false
}
include ":app"

View file

@ -0,0 +1,28 @@
pluginManagement {
val flutterSdkPath = run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.1.0" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
id("com.google.gms.google-services") version "4.4.4" apply false
id("com.google.firebase.firebase-perf") version "2.0.2" apply false
id("com.google.firebase.crashlytics") version "3.0.6" apply false
}
include(":app")

1119
assets/js/vaft.user.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,113 +1,28 @@
## v5.0.5
## v5.3.1
January 9, 2026
October 12, 2026
### Bug Fixes
- Fixed lag/frame drops in overlay chat mode.
- Fixed draggable divider still interactable in fullscreen mode.
- Fixed landscape bottom padding in chat bottom bar.
- [iOS] Fixed user agent breaking login.
- Fixed the player getting stuck on a Source error after the screen turns off or the network switches — it now reloads automatically instead of waiting for a manual refresh.
---
## v5.0.4
## v5.3.0
January 8, 2026
### Improvements
- Swipe down to PiP gesture now works in landscape mode.
- The rotate button now adjusts to your device's physical orientation instead of always rotating to the same direction.
- Replaced "Fill notch side" setting with "Fill all edges" and made edge padding auto-adjust based on device rotation.
### Bug Fixes
- Fixed Google login not working.
- [iOS] Fixed bottom safe area in landscape mode.
---
## v5.0.3
January 6, 2026
### Improvements
- Reconnection messages now update in place instead of spamming chat.
- Chat now shows sending status and relevant error notifications when messages fail to send.
- [Android] The "Enhanced rendering" video setting has been renamed to "Fast video rendering" and reset to enabled for all users.
### Bug Fixes
- Fixed send button not working in landscape mode.
- Fixed text field input being cleared before server confirmation.
---
## v5.0.2
January 4, 2026
### Improvements
- Inline emote support in the chat text field.
- Search page now uses type-to-search for faster navigation.
- Restored ability to send messages while chat delay is active.
- Restored chat delay input indicator.
- Redesigned the reply bar.
### Bug Fixes
- Fixed reply bar and autocomplete bar blocking the bottom of chat.
- Fixed clear recent emotes button sizing.
- Fixed black screen when logging in.
---
## v5.0.1
January 2, 2026
### Improvements
- [Android] Enhanced video rendering is now enabled by default for better performance. Disable in settings if you experience crashes.
- [Android] Re-enabled Flutter Impeller rendering engine for better performance.
- Restored the "Show latency" setting in video overlay options.
### Bug Fixes
- [Android] Potentially fixed crash on app startup related to secure storage.
---
## v5.0.0
January 1, 2026
October 12, 2026
### New Features
- Open multiple chat tabs for different channels with drag-to-reorder. Tabs are saved between sessions by default (configurable in settings).
- Resize the chat and video areas in landscape mode by dragging the divider.
- A new section in the Following tab shows which of your followed channels are currently offline.
- Change your chat username color from the chat details menu.
- A new randomize button for the accent color theme in settings.
- View emotes and stream thumbnails in full resolution with swipe-to-dismiss gestures.
- [Android] Open Twitch channel links directly in Frosty instead of the browser or Twitch app (by @micahmo on GitHub).
- Added a Follow / Unfollow button under the player (between video and chat) for live and offline channels.
### Improvements
---
- Swipe down to enter Picture-in-Picture mode, and press the PiP button again to exit.
- Redesigned the video overlay and added stream details to the app bar in chat-only mode.
- Networking and data handling have been completely rewritten to be faster and more reliable.
- Improved formatting for chat replies, quick copy-to-input for messages, and a clearer countdown for chat delays.
## v5.2.9
October 7, 2026
### Bug Fixes
- Fixed chat text input not unfocusing when tapping outside of it (by @zeykafx on GitHub).
- Fixed missing badges and emotes in historical messages upon initial connection.
- Fixed accidental chat scrolling when accessing system UI gestures in landscape mode.
- Fixed chat reconnection issues when a chat delay was active.
- Fixed duplicate message cases and various UI stability issues.
#### See all release notes at [https://github.com/tommyxchow/frosty/releases](https://github.com/tommyxchow/frosty/releases)
- Fixed tag stream lists reshuffling when loading more pages by paginating with Twitch’s sorted tag directory (GQL) instead of scanning Helix.
- Fixed follow/unfollow failing with an integrity check error while logged in by confirming via a short Twitch WebView when needed.

35
docs/DESIGN-adblock.md Normal file
View file

@ -0,0 +1,35 @@
# Design: Twitch ad blocking (vaft) in Frosty fork
## Intent
Personal Forgejo fork of Frosty blocks Twitch live ads using [TwitchAdSolutions vaft](https://github.com/pixeltris/TwitchAdSolutions) ideas, adapted to Flutter.
## Upstream baseline
Frosty **did not** ship an adblocker. The native player only **detects** stitched ad breaks (`adBreakStream`) to avoid bad latency/stall recovery. Turbo/sub may get `hide_ads` via linked web GQL session — server-side, not client blocking.
## Strategy
### Native player (primary — this is the reliable path)
1. When `SettingsStore.blockTwitchAds` is true, request `PlaybackAccessToken` with `playerType=popout` (vaft `ForceAccessTokenPlayerType`) instead of `site`.
2. When the native plugin reports an ad break, resolve a backup Usher URL trying `embed` → `popout` → `autoplay`, preferring playlists whose media m3u8 does not contain `stitched`, then `loadUrl` that stream.
3. When the ad break ends, restore the primary popout URL.
4. Overlay shows “Blocking ads” while `_blockingAds` is true.
Implementation: `lib/apis/twitch_ad_solutions.dart`, `lib/apis/twitch_gql_api.dart`, `lib/screens/channel/video/native_video_store.dart`.
### WebView player (legacy, best-effort)
Inject vendored `assets/js/vaft.user.js` on page start/finish. Not true `@run-at document-start` on Android via `webview_flutter`, so native remains preferred. Enabling adblock in Settings forces native on.
## Settings
- `blockTwitchAds` (default `true`)
- Turning adblock on sets `useNativePlayer = true`
## Non-goals
- Upstream PR
- Full browser Worker prototype rewriting on native
- Third-party CDN proxies unless player-type swap proves insufficient

45
docs/OBTAINIUM.md Normal file
View file

@ -0,0 +1,45 @@
# Obtainium (Android)
Forgejo fork: [copilot/frosty](https://forgejo.fsociety00.cc/copilot/frosty) (**private**).
Releases: `.forgejo/workflows/release.yml` on runner `desktop-release` (tag `v*` or workflow_dispatch).
## Obtainium settings
| Setting | Value |
|---|---|
| Source | Forgejo |
| URL | `https://forgejo.fsociety00.cc/api/v1/repos/copilot/frosty/releases` |
| APK filter | `frosty-arm64-v8a-v.*\.apk` |
| Trim version | `v?([0-9]+\.[0-9]+\.[0-9]+)` |
| Match group | `1` |
Repo is private → add a Forgejo API token in Obtainium (same PAT as other Newas apps).
Quick add:
```text
obtainium://add/https://forgejo.fsociety00.cc/api/v1/repos/copilot/frosty/releases
```
## Signing / Play Store conflict
Package id stays `com.tommychow.frosty`. Play Store Frosty and this fork use **different signing keys**, so Obtainium cannot update stock → fork in place. Uninstall the Play build first, then install from Obtainium.
## CI secrets (Forgejo repo)
| Secret | Purpose |
|---|---|
| `RELEASE_KEYSTORE_BASE64` | Same pattern as Flow / Finamp / Jellyfin forks |
| `STORE_PASSWORD` | Keystore password |
| `KEY_ALIAS` | Key alias |
| `KEY_PASSWORD` | Key password |
| `CLIENT_ID` | Twitch Helix app client id (`--dart-define`) |
| `SECRET` | Twitch Helix app secret (`--dart-define`) |
## Cut a release
1. Bump `version:` in `pubspec.yaml` (`x.y.z+build`) if needed.
2. Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z`
or run **Actions → Release APK → Run workflow**.
3. Install / update via Obtainium once the release asset appears.

57
docs/release.md Normal file
View file

@ -0,0 +1,57 @@
# Release — version, notes, tag, then stores
Cut a GitHub + Play + App Store release from `main`. Not a changelog dump. Not a docs rewrite.
This playbook lives in the repo and loads only when you open this workspace and ask to release. It is not a global skill.
Flow: **preflight → bump + notes → commit on `main` → tag → wait for workflows → publish the GitHub draft.**
The tag is the mechanical trigger. This file is the judgment pass around it.
## Preflight
Main CI green on current `HEAD`. Then `gh workflow run "Release dry-run"` on that `main` — the monthly heartbeat is not enough after a toolchain bump.
Click any pending Apple agreement first (`developer.apple.com/account`, then App Store Connect Business) or the iOS job 403s. There is no API for that. On Play, glance Console Home and **Policy and programs > App content** (`Needs attention`); a DDA banner or new declaration can block the AAB the same way.
## Version
Release commit on `main` is only `pubspec.yaml` (`x.y.z+build`, bump both) and `assets/release-notes.md`. Message: `release vX.Y.Z`.
Features since last tag → minor. Fixes only → patch. Build number +1.
Do not put `AGENTS.md` or other docs in that commit.
## Notes
Write for someone opening the app, not the commit log. Match the latest shipped version's voice (`Added` / `Improved` / `Fixed`). Skip CI, Flutter upgrades, codegen, and anything a user wouldn't notice; the GitHub compare link is the rest.
Keep about three most recent versions, consecutive. Drop from the oldest end. Never skip a version in the middle. GitHub releases keep the full history.
- Features that didn't exist last release. Improvements only for behavior that already shipped — gating a new feature (opt-in permissions, an Enable flow) is not an improvement.
- If you can't remember the user-facing symptom, it doesn't belong.
- `[Android]` / `[iOS]` on platform-only lines. Cluster them: one tagged line at the end of its section, two or more adjacent.
- Credit GitHub PR authors as `(by @user on GitHub)` on each bullet they landed. Not AI, not issue reporters. The GitHub release body strips ` on GitHub` as redundant.
Example shape (from v5.1.0): `Added a new experimental native video player option with quality selection…` not `Bumped better_native_video_player`.
## Tag
```bash
git commit -m "release vX.Y.Z"
git push origin main
git tag vX.Y.Z # lightweight, on that commit
git push origin vX.Y.Z # v*.*.* triggers both workflows
```
Do not `gh release create`. **Draft GitHub release** attaches `frosty-vX.Y.Z.apk` and fills the body from this version's section in `assets/release-notes.md`. **Build and release Frosty** uploads the Play AAB and submits iOS for review (`automatic_release: true`).
When the draft has the APK:
```bash
gh release edit vX.Y.Z --draft=false
```
The README latest-release badge stays stale until that publish. Store listing copy already points at GitHub — don't add per-version Play changelog files.
Do not push the tag until the release commit is on `origin/main`.

View file

@ -20,7 +20,5 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>

View file

@ -3,13 +3,13 @@ GEM
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.8.8)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1200.0)
aws-sdk-core (3.240.0)
aws-partitions (1.1260.0)
aws-sdk-core (3.252.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@ -17,18 +17,19 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sdk-kms (1.129.0)
aws-sdk-core (~> 3, >= 3.248.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.209.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-s3 (1.225.1)
aws-sdk-core (~> 3, >= 3.248.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
bigdecimal (4.0.1)
base64 (0.3.0)
benchmark (0.5.0)
bigdecimal (4.1.2)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
@ -42,7 +43,7 @@ GEM
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.112.0)
faraday (1.10.4)
faraday (1.10.5)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
@ -67,19 +68,20 @@ GEM
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday-retry (1.0.4)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.230.0)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
fastimage (2.4.1)
fastlane (2.234.0)
CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
aws-sdk-s3 (~> 1.197)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2.0)
bundler (>= 1.12.0, < 3.0.0)
base64 (~> 0.2)
benchmark (>= 0.1.0)
bundler (>= 1.17.3, < 5.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
@ -90,11 +92,11 @@ GEM
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.0.0)
fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-env (>= 1.6.0, <= 2.1.1)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
@ -103,10 +105,11 @@ GEM
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3.0)
mutex_m (~> 0.3)
naturally (~> 2.2)
nkf (~> 0.2.0)
nkf (~> 0.2)
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
@ -119,41 +122,42 @@ GEM
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.3)
google-apis-androidpublisher_v3 (0.102.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0)
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.8.0)
google-apis-iamcredentials_v1 (0.27.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.63.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.9.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.5.0)
google-cloud-storage (1.47.0)
google-cloud-env (2.1.1)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.6.0)
google-cloud-storage (1.61.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.31.0)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
googleauth (~> 1.9)
mini_mime (~> 1.0)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
googleauth (1.11.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.1)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
@ -164,42 +168,41 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.18.0)
jwt (2.10.2)
json (2.19.9)
jwt (2.10.3)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.19.1)
multi_json (1.21.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
nkf (0.2.0)
nkf (0.3.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
public_suffix (7.0.0)
rake (13.3.1)
public_suffix (7.0.5)
rake (13.4.2)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
retriable (3.8.0)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.21.0)
signet (0.22.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)

View file

@ -1,321 +1,27 @@
PODS:
- advanced_in_app_review (0.0.1):
- Flutter
- app_links (7.0.0):
- Flutter
- device_info_plus (0.0.1):
- Flutter
- Firebase/CoreOnly (12.6.0):
- FirebaseCore (~> 12.6.0)
- Firebase/Crashlytics (12.6.0):
- Firebase/CoreOnly
- FirebaseCrashlytics (~> 12.6.0)
- Firebase/Performance (12.6.0):
- Firebase/CoreOnly
- FirebasePerformance (~> 12.6.0)
- firebase_analytics (12.1.0):
- firebase_core
- FirebaseAnalytics (= 12.6.0)
- Flutter
- firebase_core (4.3.0):
- Firebase/CoreOnly (= 12.6.0)
- Flutter
- firebase_crashlytics (5.0.6):
- Firebase/Crashlytics (= 12.6.0)
- firebase_core
- Flutter
- firebase_performance (0.11.1-3):
- Firebase/Performance (= 12.6.0)
- firebase_core
- Flutter
- FirebaseABTesting (12.6.0):
- FirebaseCore (~> 12.6.0)
- FirebaseAnalytics (12.6.0):
- FirebaseAnalytics/Default (= 12.6.0)
- FirebaseCore (~> 12.6.0)
- FirebaseInstallations (~> 12.6.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- FirebaseAnalytics/Default (12.6.0):
- FirebaseCore (~> 12.6.0)
- FirebaseInstallations (~> 12.6.0)
- GoogleAppMeasurement/Default (= 12.6.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- FirebaseCore (12.6.0):
- FirebaseCoreInternal (~> 12.6.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1)
- FirebaseCoreExtension (12.6.0):
- FirebaseCore (~> 12.6.0)
- FirebaseCoreInternal (12.6.0):
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- FirebaseCrashlytics (12.6.0):
- FirebaseCore (~> 12.6.0)
- FirebaseInstallations (~> 12.6.0)
- FirebaseRemoteConfigInterop (~> 12.6.0)
- FirebaseSessions (~> 12.6.0)
- GoogleDataTransport (~> 10.1)
- GoogleUtilities/Environment (~> 8.1)
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- FirebaseInstallations (12.6.0):
- FirebaseCore (~> 12.6.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- PromisesObjC (~> 2.4)
- FirebasePerformance (12.6.0):
- FirebaseCore (~> 12.6.0)
- FirebaseInstallations (~> 12.6.0)
- FirebaseRemoteConfig (~> 12.6.0)
- FirebaseSessions (~> 12.6.0)
- GoogleDataTransport (~> 10.1)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- nanopb (~> 3.30910.0)
- FirebaseRemoteConfig (12.6.0):
- FirebaseABTesting (~> 12.6.0)
- FirebaseCore (~> 12.6.0)
- FirebaseInstallations (~> 12.6.0)
- FirebaseRemoteConfigInterop (~> 12.6.0)
- FirebaseSharedSwift (~> 12.6.0)
- GoogleUtilities/Environment (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- FirebaseRemoteConfigInterop (12.6.0)
- FirebaseSessions (12.6.0):
- FirebaseCore (~> 12.6.0)
- FirebaseCoreExtension (~> 12.6.0)
- FirebaseInstallations (~> 12.6.0)
- GoogleDataTransport (~> 10.1)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- nanopb (~> 3.30910.0)
- PromisesSwift (~> 2.1)
- FirebaseSharedSwift (12.6.0)
- Flutter (1.0.0)
- flutter_native_splash (2.4.3):
- Flutter
- flutter_secure_storage (6.0.0):
- Flutter
- GoogleAdsOnDeviceConversion (3.2.0):
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/Core (12.6.0):
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/Default (12.6.0):
- GoogleAdsOnDeviceConversion (~> 3.2.0)
- GoogleAppMeasurement/Core (= 12.6.0)
- GoogleAppMeasurement/IdentitySupport (= 12.6.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/IdentitySupport (12.6.0):
- GoogleAppMeasurement/Core (= 12.6.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- GoogleDataTransport (10.1.0):
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- GoogleUtilities/AppDelegateSwizzler (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
- GoogleUtilities/Network
- GoogleUtilities/Privacy
- GoogleUtilities/Environment (8.1.0):
- GoogleUtilities/Privacy
- GoogleUtilities/Logger (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/MethodSwizzler (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilities/Network (8.1.0):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Privacy
- GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (8.1.0)":
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (8.1.0)
- GoogleUtilities/Reachability (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilities/UserDefaults (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- in_app_review (2.0.0):
- Flutter
- nanopb (3.30910.0):
- nanopb/decode (= 3.30910.0)
- nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- native_device_orientation (0.0.1):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- PromisesObjC (2.4.0)
- PromisesSwift (2.4.0):
- PromisesObjC (= 2.4.0)
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
- wakelock_plus (0.0.1):
- Flutter
- webview_flutter_wkwebview (0.0.1):
- Flutter
- FlutterMacOS
DEPENDENCIES:
- advanced_in_app_review (from `.symlinks/plugins/advanced_in_app_review/ios`)
- app_links (from `.symlinks/plugins/app_links/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`)
- firebase_performance (from `.symlinks/plugins/firebase_performance/ios`)
- Flutter (from `Flutter`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
- native_device_orientation (from `.symlinks/plugins/native_device_orientation/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
SPEC REPOS:
trunk:
- Firebase
- FirebaseABTesting
- FirebaseAnalytics
- FirebaseCore
- FirebaseCoreExtension
- FirebaseCoreInternal
- FirebaseCrashlytics
- FirebaseInstallations
- FirebasePerformance
- FirebaseRemoteConfig
- FirebaseRemoteConfigInterop
- FirebaseSessions
- FirebaseSharedSwift
- GoogleAdsOnDeviceConversion
- GoogleAppMeasurement
- GoogleDataTransport
- GoogleUtilities
- nanopb
- PromisesObjC
- PromisesSwift
EXTERNAL SOURCES:
advanced_in_app_review:
:path: ".symlinks/plugins/advanced_in_app_review/ios"
app_links:
:path: ".symlinks/plugins/app_links/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
firebase_analytics:
:path: ".symlinks/plugins/firebase_analytics/ios"
firebase_core:
:path: ".symlinks/plugins/firebase_core/ios"
firebase_crashlytics:
:path: ".symlinks/plugins/firebase_crashlytics/ios"
firebase_performance:
:path: ".symlinks/plugins/firebase_performance/ios"
Flutter:
:path: Flutter
flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
in_app_review:
:path: ".symlinks/plugins/in_app_review/ios"
native_device_orientation:
:path: ".symlinks/plugins/native_device_orientation/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
SPEC CHECKSUMS:
advanced_in_app_review: fe8fd50b8670cb3dbe67e4c26ca643f7aea8dbb8
app_links: 6d01271b3907b0ee7325c5297c75d697c4226c4d
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
Firebase: a451a7b61536298fd5cbfe3a746fd40443a50679
firebase_analytics: 1c038d9e4edf25828a8985c0be4efcb84cdae32a
firebase_core: 7ca5e04fc97329a2245376eba53c5bed113ca21e
firebase_crashlytics: eb4e3214716bff15ccd3a554dd1cf7922115c430
firebase_performance: 2ff356e47b63483a0da655b73c70eb780838c4e3
FirebaseABTesting: 119f0a2b2e68b1ae05d248c5adb2455f148f20c1
FirebaseAnalytics: d0a97a0db6425e5a5d966340b87f92ca7b13a557
FirebaseCore: 0e38ad5d62d980a47a64b8e9301ffa311457be04
FirebaseCoreExtension: 032fd6f8509e591fda8cb76f6651f20d926b121f
FirebaseCoreInternal: 69bf1306a05b8ac43004f6cc1f804bb7b05b229e
FirebaseCrashlytics: 3d6248c50726ee7832aef0e53cb84c9e64d9fa7e
FirebaseInstallations: 631b38da2e11a83daa4bfb482f79d286a5dfa7ad
FirebasePerformance: 9a73b170cefb06e5da537682e30f86c9865880c5
FirebaseRemoteConfig: c5dfe22828a7ae7673d16224ea92743687e993df
FirebaseRemoteConfigInterop: 3443b8cb8fffd76bb3e03b2a84bfd3db952fcda4
FirebaseSessions: 2e8f808347e665dff3e5843f275715f07045297d
FirebaseSharedSwift: 79f27fff0addd15c3de19b87fba426f3cc2c964f
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
GoogleAdsOnDeviceConversion: d68c69dd9581a0f5da02617b6f377e5be483970f
GoogleAppMeasurement: 3bf40aff49a601af5da1c3345702fcb4991d35ee
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
in_app_review: 436034b18594851a7328d7f1c2ed5ec235b79cfc
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
native_device_orientation: 42487b19c552fb5a6a2b51e61b93366f628113df
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa
wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e
webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4
advanced_in_app_review: daebe4c801525b756fe589de60fd327cb12f965c
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
PODFILE CHECKSUM: 57c8aed26fba39d3ec9424816221f294a07c58eb

View file

@ -11,10 +11,13 @@
3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E751BC99755AAC391DC0B29F /* Pods_Runner.framework */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
F1C00CE01A2B3C4D00E5F6A8 /* CookieExtractorPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
BE8095795EAFF58C08A43B48 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A68609F67C30CFEF267CD4D3 /* GoogleService-Info.plist */; };
D4F5A60712340001ABCD1234 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
@ -37,6 +40,7 @@
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieExtractorPlugin.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
95D7E205EE04D01F6996ECFC /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
@ -48,7 +52,9 @@
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A68609F67C30CFEF267CD4D3 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = "<group>"; };
CFF3F6A0DA5A835B219C2D3C /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
E751BC99755AAC391DC0B29F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -56,6 +62,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -66,6 +73,7 @@
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@ -104,7 +112,9 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */,
);
path = Runner;
sourceTree = "<group>";
@ -131,6 +141,9 @@
/* Begin PBXNativeTarget section */
97C146ED1CF9000F007C117D /* Runner */ = {
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
@ -157,6 +170,9 @@
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1510;
@ -196,6 +212,7 @@
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
BE8095795EAFF58C08A43B48 /* GoogleService-Info.plist in Resources */,
D4F5A60712340001ABCD1234 /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -242,7 +259,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"$PODS_ROOT/FirebaseCrashlytics/upload-symbols\" --flutter-project \"$PROJECT_DIR/firebase_app_id_file.json\" ";
shellScript = "# Symbol upload only matters for builds that ship; Debug dSYMs are absent or empty and fail the tool.\nif [ \"$CONFIGURATION\" = \"Debug\" ]; then\n echo \"Skipping Crashlytics symbol upload for Debug build\"\n exit 0\nfi\n# Locate upload-symbols whether Firebase is integrated via CocoaPods or Swift Package Manager.\nBIN=\"$PODS_ROOT/FirebaseCrashlytics/upload-symbols\"\n[ -f \"$BIN\" ] || BIN=\"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/upload-symbols\"\n\"$BIN\" --flutter-project \"$PROJECT_DIR/firebase_app_id_file.json\"\n";
};
34CC65D58A1948B306772884 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
@ -300,6 +317,7 @@
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
F1C00CE01A2B3C4D00E5F6A8 /* CookieExtractorPlugin.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -367,7 +385,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@ -449,7 +467,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@ -498,7 +516,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@ -586,6 +604,18 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View 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" : "bb4002485ff867768dec13bf904a2ddb050bd1b1",
"version" : "11.3.0"
}
},
{
"identity" : "firebase-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : {
"revision" : "33a468adfdb75b53f05a37e7c886ca7c962b5c17",
"version" : "12.17.0"
}
},
{
"identity" : "google-ads-on-device-conversion-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : {
"revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64",
"version" : "3.6.0"
}
},
{
"identity" : "googleappmeasurement",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : {
"revision" : "fceaffa07d22dcd5624d3639fd970351a4a5ad8c",
"version" : "12.17.0"
}
},
{
"identity" : "googledatatransport",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git",
"state" : {
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
"version" : "10.1.0"
}
},
{
"identity" : "googleutilities",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "c46e5f8b7c23265f17c24ca7f9fa1b13ded7a822",
"version" : "8.1.1"
}
},
{
"identity" : "grpc-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/grpc-binary.git",
"state" : {
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
"version" : "1.69.1"
}
},
{
"identity" : "gtm-session-fetcher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : {
"revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a",
"version" : "5.3.0"
}
},
{
"identity" : "interop-ios-for-google-sdks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
"state" : {
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
"version" : "101.0.0"
}
},
{
"identity" : "leveldb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/leveldb.git",
"state" : {
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
"version" : "1.22.5"
}
},
{
"identity" : "nanopb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git",
"state" : {
"revision" : "3851d94a41890dea16dc3db34caf60e585cb4163",
"version" : "2.30910.1"
}
},
{
"identity" : "promises",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
"version" : "2.4.1"
}
}
],
"version" : 2
}

View file

@ -5,6 +5,24 @@
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"

View 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" : "bb4002485ff867768dec13bf904a2ddb050bd1b1",
"version" : "11.3.0"
}
},
{
"identity" : "firebase-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : {
"revision" : "33a468adfdb75b53f05a37e7c886ca7c962b5c17",
"version" : "12.17.0"
}
},
{
"identity" : "google-ads-on-device-conversion-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : {
"revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64",
"version" : "3.6.0"
}
},
{
"identity" : "googleappmeasurement",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : {
"revision" : "fceaffa07d22dcd5624d3639fd970351a4a5ad8c",
"version" : "12.17.0"
}
},
{
"identity" : "googledatatransport",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git",
"state" : {
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
"version" : "10.1.0"
}
},
{
"identity" : "googleutilities",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "c46e5f8b7c23265f17c24ca7f9fa1b13ded7a822",
"version" : "8.1.1"
}
},
{
"identity" : "grpc-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/grpc-binary.git",
"state" : {
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
"version" : "1.69.1"
}
},
{
"identity" : "gtm-session-fetcher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : {
"revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a",
"version" : "5.3.0"
}
},
{
"identity" : "interop-ios-for-google-sdks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
"state" : {
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
"version" : "101.0.0"
}
},
{
"identity" : "leveldb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/leveldb.git",
"state" : {
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
"version" : "1.22.5"
}
},
{
"identity" : "nanopb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git",
"state" : {
"revision" : "3851d94a41890dea16dc3db34caf60e585cb4163",
"version" : "2.30910.1"
}
},
{
"identity" : "promises",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
"version" : "2.4.1"
}
}
],
"version" : 2
}

View file

@ -2,12 +2,23 @@ import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
guard let cookieExtractorRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "CookieExtractorPlugin"
) else {
assertionFailure("Failed to create CookieExtractorPlugin registrar")
return
}
CookieExtractorPlugin.register(with: cookieExtractorRegistrar)
}
}

View 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)
}
}
}

View file

@ -52,7 +52,32 @@
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIStatusBarHidden</key>
<false/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyTracking</key>
<false/>
</dict>
</plist>

View file

@ -17,7 +17,9 @@ default_platform(:ios)
platform :ios do
desc 'Push a new release build to the App Store'
lane :release do
lane :release do |options|
dry_run = options[:dry_run] || false
# Setup for CI. Uses a keychain to prevent the CI from hanging due to password prompts.
setup_ci
@ -29,8 +31,9 @@ platform :ios do
is_key_content_base64: true
)
# Manage code signing (with match).
sync_code_signing
# Manage code signing (with match). readonly on a dry run so we only verify the
# existing cert/profile resolve and never regenerate or mutate the certificates repo.
sync_code_signing(readonly: dry_run)
# Automatic signing is used during development, but fastlane needs to use manual signing.
update_code_signing_settings(
@ -45,6 +48,11 @@ platform :ios do
output_directory: '../build/ios/archive'
)
# On a dry run, stop here: the signed archive above exercises every step that
# actually fails (ASC key, match certs/profiles, signing, release build). The
# App Store upload itself is the only remaining unverified step.
next if dry_run
# Upload the app to the App Store.
upload_to_app_store(
submit_for_review: true,

View file

@ -1 +1 @@
Frosty for Twitch
Frosty

View file

@ -1 +1 @@
https://www.privacypolicies.com/live/095b4264-31b1-4280-837e-5843abde8b16
https://frostyapp.io/privacy

View file

@ -1 +1 @@
Watch and chat on Twitch with your favorite emotes from 7TV, BTTV, and FFZ!
Watch live streams and see community emotes in chat.

View file

@ -1 +1 @@
See 7TV, BTTV, and FFZ emotes!
Community emotes in chat

View file

@ -1,4 +1,5 @@
import 'package:dio/dio.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
/// Common type aliases to reduce repetition
typedef JsonMap = Map<String, dynamic>;
@ -43,6 +44,13 @@ class UnauthorizedException extends ApiException {
: super(message, 401, DioExceptionType.badResponse);
}
/// Twitch GQL rejected the request for a missing/invalid Client-Integrity token.
class IntegrityCheckException extends ApiException {
const IntegrityCheckException([
String message = 'Twitch integrity check failed',
]) : super(message);
}
/// Base API client to eliminate code duplication across API services
abstract class BaseApiClient {
final Dio _dio;
@ -157,29 +165,40 @@ abstract class BaseApiClient {
/// Converts DioException to appropriate ApiException subtype
ApiException _handleError(DioException error) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return TimeoutException(_getTimeoutMessage(error.type));
final exception = switch (error.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.sendTimeout ||
DioExceptionType.receiveTimeout ||
DioExceptionType.transformTimeout =>
TimeoutException(_getTimeoutMessage(error.type)),
DioExceptionType.connectionError => NetworkException(
'No internet connection. Please check your network.',
),
DioExceptionType.badResponse => _handleHttpError(error),
DioExceptionType.cancel => ApiException('Request was cancelled'),
DioExceptionType.badCertificate =>
ApiException('Security certificate error'),
DioExceptionType.unknown =>
ApiException(error.message ?? 'An unexpected error occurred'),
};
case DioExceptionType.connectionError:
return NetworkException(
'No internet connection. Please check your network.',
// Record server errors and unexpected failures to Crashlytics.
// Skip timeouts, network errors (normal offline conditions), 401s, and cancellations.
if (exception is ServerException ||
error.type == DioExceptionType.unknown ||
error.type == DioExceptionType.badCertificate) {
try {
FirebaseCrashlytics.instance.recordError(
exception,
error.stackTrace,
reason: '${error.requestOptions.method} ${error.requestOptions.uri}',
);
case DioExceptionType.badResponse:
return _handleHttpError(error);
case DioExceptionType.cancel:
return ApiException('Request was cancelled');
case DioExceptionType.badCertificate:
return ApiException('Security certificate error');
case DioExceptionType.unknown:
return ApiException(error.message ?? 'An unexpected error occurred');
} catch (_) {
// Firebase may not be initialized (e.g., in tests).
}
}
return exception;
}
/// Gets specific timeout message based on timeout type
@ -191,6 +210,8 @@ abstract class BaseApiClient {
return 'Upload timeout. Please try again.';
case DioExceptionType.receiveTimeout:
return 'Server response timeout. Please try again.';
case DioExceptionType.transformTimeout:
return 'Response processing timeout. Please try again.';
default:
return 'Request timeout. Please try again.';
}

View file

@ -1,5 +1,6 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:frosty/apis/firebase_performance_interceptor.dart';
/// Centralized Dio client configuration with interceptors and error handling
class DioClient {
@ -55,6 +56,9 @@ class DioClient {
);
}
// Trace HTTP requests as Firebase Performance metrics
dio.interceptors.add(FirebasePerformanceInterceptor());
// Add any additional interceptors provided
if (additionalInterceptors != null) {
for (final interceptor in additionalInterceptors) {

View file

@ -42,7 +42,7 @@ class FFZApi extends BaseApiClient {
);
}
/// Returns a map of badges user IDs to a list of their FFZ badges.
/// Returns a map of user IDs to a list of their FFZ badges.
Future<Map<String, List<ChatBadge>>> getBadges() async {
final data = await get<JsonMap>('/badges/ids');

View file

@ -0,0 +1,46 @@
import 'package:dio/dio.dart';
import 'package:firebase_performance/firebase_performance.dart';
/// Dio interceptor that traces HTTP requests as Firebase Performance metrics.
class FirebasePerformanceInterceptor extends Interceptor {
static const _metricKey = 'firebase_performance_metric';
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final metric = FirebasePerformance.instance.newHttpMetric(
options.uri.toString(),
_getHttpMethod(options.method),
);
metric.start();
options.extra[_metricKey] = metric;
handler.next(options);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
_stopMetric(response.requestOptions, response.statusCode);
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
_stopMetric(err.requestOptions, err.response?.statusCode);
handler.next(err);
}
void _stopMetric(RequestOptions options, int? statusCode) {
final metric = options.extra.remove(_metricKey) as HttpMetric?;
if (metric == null) return;
if (statusCode != null) metric.httpResponseCode = statusCode;
metric.stop();
}
static HttpMethod _getHttpMethod(String method) => switch (method.toUpperCase()) {
'GET' => HttpMethod.Get,
'POST' => HttpMethod.Post,
'PUT' => HttpMethod.Put,
'DELETE' => HttpMethod.Delete,
'PATCH' => HttpMethod.Patch,
_ => HttpMethod.Get,
};
}

View file

@ -0,0 +1,163 @@
import 'package:flutter/foundation.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:frosty/models/playback_access_token.dart';
/// Dart-side port of the [TwitchAdSolutions vaft](https://github.com/pixeltris/TwitchAdSolutions)
/// strategy for Frosty's native HLS player.
///
/// Browser vaft hooks `Worker`/`fetch` to rewrite playlists. Here we instead:
/// 1. Request playback tokens with a non-`site` [forceAccessTokenPlayerType]
/// (vaft's `ForceAccessTokenPlayerType`).
/// 2. When stitched ads still appear, try [backupPlayerTypes] until a playlist
/// without the ad signifier is found, then hand that Usher URL to the player.
class TwitchAdSolutions {
TwitchAdSolutions._();
/// Matches vaft `AdSignifier` — Twitch marks ad pods with this in `#EXT-X-DATERANGE`.
static const adSignifier = 'stitched';
/// vaft `ForceAccessTokenPlayerType` — used for the primary stream when
/// ad blocking is enabled.
static const forceAccessTokenPlayerType = 'popout';
/// vaft `BackupPlayerTypes` order when the primary playlist still has ads.
static const backupPlayerTypes = <String>[
'embed',
'popout',
'autoplay',
];
/// vaft `FallbackPlayerType` when every backup still carries ads.
static const fallbackPlayerType = 'embed';
/// GQL `platform` for a given player type (vaft uses `android` for autoplay).
static String platformForPlayerType(String playerType) =>
playerType == 'autoplay' ? 'android' : 'web';
static bool playlistContainsAds(String playlistText) =>
playlistText.contains(adSignifier);
/// True when [text] looks like a midroll (vaft midroll banner path).
static bool playlistIsMidroll(String playlistText) =>
playlistText.contains('"MIDROLL"') ||
playlistText.contains('"midroll"') ||
playlistText.contains("MIDROLL");
}
/// Resolves Usher HLS URLs using TwitchAdSolutions player-type strategy.
class TwitchAdFreePlayback {
TwitchAdFreePlayback(this._gql);
final TwitchGqlApi _gql;
/// Primary URL when adblock is on: force [TwitchAdSolutions.forceAccessTokenPlayerType].
Future<({String url, PlaybackAccessToken token, String playerType})>
resolvePrimary({
required String login,
String? authToken,
}) {
return _resolveForPlayerType(
login: login,
authToken: authToken,
playerType: TwitchAdSolutions.forceAccessTokenPlayerType,
requireAdFree: false,
);
}
/// Walks [TwitchAdSolutions.backupPlayerTypes] for a playlist without ads.
/// Falls back to [TwitchAdSolutions.fallbackPlayerType] even if ads remain.
Future<({String url, PlaybackAccessToken token, String playerType})>
resolveBackup({
required String login,
String? authToken,
}) async {
for (final playerType in TwitchAdSolutions.backupPlayerTypes) {
try {
final resolved = await _resolveForPlayerType(
login: login,
authToken: authToken,
playerType: playerType,
requireAdFree: true,
);
debugPrint(
'TwitchAdFreePlayback: backup OK playerType=$playerType',
);
return resolved;
} catch (e) {
debugPrint(
'TwitchAdFreePlayback: backup playerType=$playerType failed: $e',
);
}
}
debugPrint(
'TwitchAdFreePlayback: all backups had ads; using fallback '
'${TwitchAdSolutions.fallbackPlayerType}',
);
return _resolveForPlayerType(
login: login,
authToken: authToken,
playerType: TwitchAdSolutions.fallbackPlayerType,
requireAdFree: false,
);
}
Future<({String url, PlaybackAccessToken token, String playerType})>
_resolveForPlayerType({
required String login,
String? authToken,
required String playerType,
required bool requireAdFree,
}) async {
final token = await _gql.getPlaybackAccessToken(
login: login,
authToken: authToken,
playerType: playerType,
platform: TwitchAdSolutions.platformForPlayerType(playerType),
);
final url = _gql.buildHlsUrl(login: login, token: token);
if (requireAdFree) {
final hasAds = await _multivariantHasAds(url);
if (hasAds) {
throw StateError('Playlist for playerType=$playerType still has ads');
}
}
return (url: url, token: token, playerType: playerType);
}
/// Fetches the multivariant playlist and samples the first media variant for
/// the ad signifier (same idea as vaft probing backup encodings).
Future<bool> _multivariantHasAds(String hlsUrl) async {
final master = await _gql.fetchPlaylistText(hlsUrl);
final mediaUrl = firstMediaPlaylistUrl(master, baseUrl: hlsUrl);
if (mediaUrl == null) {
// Can't verify — treat as ad-free so we don't reject a usable stream.
return false;
}
final media = await _gql.fetchPlaylistText(mediaUrl);
return TwitchAdSolutions.playlistContainsAds(media);
}
/// First `.m3u8` URI in a multivariant playlist, resolved against [baseUrl].
@visibleForTesting
static String? firstMediaPlaylistUrl(
String masterPlaylist, {
required String baseUrl,
}) {
final lines = masterPlaylist.replaceAll('\r', '').split('\n');
for (final raw in lines) {
final line = raw.trim();
if (line.isEmpty || line.startsWith('#')) continue;
if (!line.contains('.m3u8')) continue;
return Uri.parse(baseUrl).resolve(line).toString();
}
return null;
}
}
/// Tiny blank MP4 (same payload vaft serves for stripped ad segments). Exposed
/// for tests / future local playlist rewriting; not required for URL-swap mode.
const twitchAdSolutionsBlankSegmentDataUri =
'data:video/mp4;base64,AAAAKGZ0eXBtcDQyAAAAAWlzb21tcDQyZGFzaGF2YzFpc282aGxzZgAABEltb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAYagAAAAAAABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAABqHRyYWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAURtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAALuAAAAAAFXEAAAAAAAtaGRscgAAAAAAAAAAc291bgAAAAAAAAAAAAAAAFNvdW5kSGFuZGxlcgAAAADvbWluZgAAABBzbWhkAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAACzc3RibAAAAGdzdHNkAAAAAAAAAAEAAABXbXA0YQAAAAAAAAABAAAAAAAAAAAAAgAQAAAAALuAAAAAAAAzZXNkcwAAAAADgICAIgABAASAgIAUQBUAAAAAAAAAAAAAAAWAgIACEZAGgICAAQIAAAAQc3R0cwAAAAAAAAAAAAAAEHN0c2MAAAAAAAAAAAAAABRzdHN6AAAAAAAAAAAAAAAAAAAAEHN0Y28AAAAAAAAAAAAAAeV0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAoAAAAFoAAAAAAGBbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAA9CQAAAAABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABLG1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAOxzdGJsAAAAoHN0c2QAAAAAAAAAAQAAAJBhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAoABaABIAAAASAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAOmF2Y0MBTUAe/+EAI2dNQB6WUoFAX/LgLUBAQFAAAD6AAA6mDgAAHoQAA9CW7y4KAQAEaOuPIAAAABBzdHRzAAAAAAAAAAAAAAAQc3RzYwAAAAAAAAAAAAAAFHN0c3oAAAAAAAAAAAAAAAAAAAAQc3RjbwAAAAAAAAAAAAAASG12ZXgAAAAgdHJleAAAAAAAAAABAAAAAQAAAC4AAAAAAoAAAAAAACB0cmV4AAAAAAAAAAIAAAABAACCNQAAAAACQAAA';

View file

@ -145,17 +145,29 @@ class TwitchApi extends BaseApiClient {
return data['access_token'] as String;
}
/// Returns a bool indicating the validity of the given token.
Future<bool> validateToken({required String token}) async {
/// Validates [token] with Twitch.
///
/// Returns token info (scopes + optional `user_id`) when valid, or `null`
/// when invalid/expired. App-access tokens may omit `user_id` and return an
/// empty scope list.
Future<TwitchTokenInfo?> validateToken({required String token}) async {
try {
await get<JsonMap>(
final data = await get<JsonMap>(
'$_oauthBaseUrl/validate',
headers: {'Authorization': 'Bearer $token'},
);
return true;
final scopes = data['scopes'];
final scopeList = scopes is List
? scopes.map((scope) => scope.toString()).toList()
: const <String>[];
final userId = data['user_id']?.toString();
return TwitchTokenInfo(
scopes: scopeList,
userId: userId == null || userId.isEmpty ? null : userId,
);
} on UnauthorizedException {
// 401 -> token is invalid/expired (propagated from interceptor for validate requests)
return false;
return null;
} on ApiException catch (e) {
// Network/timeout/server errors -> treat as indeterminate, not invalid
debugPrint('Token validation indeterminate: $e');
@ -163,11 +175,23 @@ class TwitchApi extends BaseApiClient {
}
}
/// Returns a [StreamsTwitch] object that contains the top 20 streams and a cursor for further requests.
Future<StreamsTwitch> getTopStreams({String? cursor}) async {
/// Returns a [StreamsTwitch] object that contains top streams and a cursor for further requests.
///
/// [language] is an optional ISO 639-1 code (Helix accepts only one).
Future<StreamsTwitch> getTopStreams({
String? cursor,
String? language,
int first = 100,
}) async {
final queryParams = <String, dynamic>{
'first': first,
if (cursor != null) 'after': cursor,
if (language != null && language.isNotEmpty) 'language': language,
};
final data = await get<JsonMap>(
'/streams',
queryParameters: cursor != null ? {'after': cursor} : null,
queryParameters: queryParams,
);
return StreamsTwitch.fromJson(data);
@ -178,7 +202,7 @@ class TwitchApi extends BaseApiClient {
required String id,
String? cursor,
}) async {
final queryParams = {'user_id': id};
final queryParams = {'user_id': id, 'first': '100'};
if (cursor != null) queryParams['after'] = cursor;
final data = await get<JsonMap>(
@ -194,7 +218,7 @@ class TwitchApi extends BaseApiClient {
required String userId,
String? cursor,
}) async {
final queryParams = {'user_id': userId, 'first': '20'};
final queryParams = {'user_id': userId, 'first': '100'};
if (cursor != null) queryParams['after'] = cursor;
final data = await get<JsonMap>(
@ -205,19 +229,153 @@ class TwitchApi extends BaseApiClient {
return FollowedChannels.fromJson(data);
}
/// Whether [userId] follows [broadcasterId], via Helix (read-only; create/delete
/// follows were removed from the public API).
Future<bool> isFollowingChannel({
required String userId,
required String broadcasterId,
}) async {
final data = await get<JsonMap>(
'/channels/followed',
queryParameters: {
'user_id': userId,
'broadcaster_id': broadcasterId,
'first': '1',
},
);
final list = data['data'] as List? ?? const [];
return list.isNotEmpty;
}
/// Returns a [StreamsTwitch] object that contains the list of streams under the given game/category ID.
Future<StreamsTwitch> getStreamsUnderCategory({
required String gameId,
String? cursor,
String? language,
int first = 100,
}) async {
final queryParams = {'game_id': gameId};
if (cursor != null) queryParams['after'] = cursor;
final queryParams = <String, dynamic>{
'game_id': gameId,
'first': first,
if (cursor != null) 'after': cursor,
if (language != null && language.isNotEmpty) 'language': language,
};
final data = await get<JsonMap>('/streams', queryParameters: queryParams);
return StreamsTwitch.fromJson(data);
}
/// Fetches Helix directory pages until enough streams match [languages] and
/// optional [freeformTag] (case-insensitive tag name), or the directory ends.
///
/// Helix is always viewer-desc and supports at most one `language` query
/// param; tags are filtered client-side. Used as fallback when GQL directory
/// pagination lacks a Client-Integrity token.
///
/// When a [freeformTag] filter yields no matches in a Helix window, this
/// keeps scanning (up to [maxPages]) and still returns the Helix cursor so
/// callers can continue — an empty match set alone is not end-of-list.
Future<StreamsTwitch> getDirectoryStreams({
List<String> languages = const [],
String? gameId,
String? freeformTag,
String? cursor,
int minCount = 50,
int maxPages = 12,
}) async {
final wantedLangs = languages
.map((l) => l.trim().toLowerCase())
.where((l) => l.isNotEmpty)
.toSet();
final tagNeedle = freeformTag?.trim().toLowerCase();
final hasTag = tagNeedle != null && tagNeedle.isNotEmpty;
bool matches(StreamTwitch stream) {
if (wantedLangs.isNotEmpty &&
!wantedLangs.contains(stream.language.toLowerCase())) {
return false;
}
if (hasTag) {
final hit = stream.tags.any((t) => t.toLowerCase() == tagNeedle);
if (!hit) return false;
}
return true;
}
// Fast path: no tag filter and 0–1 languages → one Helix call.
if (!hasTag && wantedLangs.length <= 1) {
final lang = wantedLangs.isEmpty ? null : wantedLangs.first;
return gameId == null
? getTopStreams(cursor: cursor, language: lang)
: getStreamsUnderCategory(
gameId: gameId,
cursor: cursor,
language: lang,
);
}
// Sparse tags need a deeper Helix scan per request.
final effectiveMin = hasTag ? minCount.clamp(1, 100) : minCount;
final effectiveMax = hasTag ? (maxPages < 25 ? 25 : maxPages) : maxPages;
// When filtering by tag and exactly one language, still pass language to
// Helix so each page is denser in matches.
final helixLanguage =
wantedLangs.length == 1 ? wantedLangs.first : null;
final matched = <StreamTwitch>[];
String? pageCursor = cursor;
String? nextCursor;
var pages = 0;
while (matched.length < effectiveMin && pages < effectiveMax) {
final page = gameId == null
? await getTopStreams(cursor: pageCursor, language: helixLanguage)
: await getStreamsUnderCategory(
gameId: gameId,
cursor: pageCursor,
language: helixLanguage,
);
pages++;
nextCursor = page.pagination['cursor'];
for (final stream in page.data) {
if (matches(stream)) matched.add(stream);
}
if (nextCursor == null ||
nextCursor.isEmpty ||
page.data.isEmpty) {
nextCursor = null;
break;
}
pageCursor = nextCursor;
}
return StreamsTwitch(
matched,
nextCursor != null && nextCursor.isNotEmpty
? {'cursor': nextCursor}
: const {},
);
}
/// Deprecated name — use [getDirectoryStreams].
Future<StreamsTwitch> getStreamsMatchingLanguages({
required List<String> languages,
String? gameId,
String? cursor,
int minCount = 40,
int maxPages = 8,
}) =>
getDirectoryStreams(
languages: languages,
gameId: gameId,
cursor: cursor,
minCount: minCount,
maxPages: maxPages,
);
/// Returns a [StreamTwitch] object containing the stream info associated with the given [userLogin].
Future<StreamTwitch> getStream({required String userLogin}) async {
final data = await get<JsonMap>(
@ -465,4 +623,131 @@ class TwitchApi extends BaseApiClient {
return data['messages'] as JsonList;
}
/// Returns a list of broadcaster IDs for channels the authenticated user
/// moderates, following pagination so moderators of more than one page of
/// channels aren't silently truncated at the first 100.
Future<List<String>> getModeratedChannels({
required String id,
String? cursor,
}) async {
try {
// Default page size is 20; request the max to keep the request count low.
final queryParams = {'user_id': id, 'first': '100'};
if (cursor != null) queryParams['after'] = cursor;
final data = await get<JsonMap>(
'/moderation/channels',
queryParameters: queryParams,
);
final channels = data['data'] as JsonList;
final result = channels
.map((channel) => (channel as JsonMap)['broadcaster_id'] as String?)
.whereType<String>()
.toList();
final paginationCursor = data['pagination']['cursor'];
if (paginationCursor != null && channels.isNotEmpty) {
// Throttle between pages to stay under Twitch's rate limit, matching
// getUserBlockedList above.
await Future.delayed(const Duration(milliseconds: 150));
result.addAll(
await getModeratedChannels(id: id, cursor: paginationCursor),
);
}
return result;
} on ApiException catch (e) {
// Backstop: callers should skip this without moderator scopes, but a
// stale/partial token must not take down chat.
debugPrint('Failed to get moderated channels: $e');
return [];
}
}
/// Deletes a specific chat message.
Future<bool> deleteChatMessage({
required String broadcasterId,
required String moderatorId,
required String messageId,
}) async {
// Let ApiException propagate so the caller can surface Twitch's reason
// (e.g. you can't delete the broadcaster's or another moderator's message)
// instead of a generic failure.
await delete<dynamic>(
'/moderation/chat',
queryParameters: {
'broadcaster_id': broadcasterId,
'moderator_id': moderatorId,
'message_id': messageId,
},
);
return true;
}
/// Bans or times out a user from a channel.
///
/// The optional `duration` in seconds will timeout the user. If omitted, the user is banned.
/// The optional `reason` will be displayed to the banned user and other moderators.
Future<bool> banUser({
required String broadcasterId,
required String moderatorId,
required String userIdToBan,
int? duration,
String? reason,
}) async {
final requestBody = {
'data': {
'user_id': userIdToBan,
'duration': ?duration,
'reason': ?reason,
},
};
// Let ApiException propagate so the caller can surface Twitch's reason
// (e.g. the target is a moderator and can't be banned) instead of a
// generic failure.
final response = await post<JsonMap>(
'/moderation/bans',
data: requestBody,
queryParameters: {
'broadcaster_id': broadcasterId,
'moderator_id': moderatorId,
},
);
final data = response['data'] as JsonList;
return data.isNotEmpty;
}
/// Removes a ban or timeout for a user in a channel.
///
/// A timeout is a time-limited ban, so this lifts both. Lets ApiException
/// propagate (e.g. the user isn't currently banned) so the caller can show
/// Twitch's reason.
Future<bool> unbanUser({
required String broadcasterId,
required String moderatorId,
required String userId,
}) async {
await delete<dynamic>(
'/moderation/bans',
queryParameters: {
'broadcaster_id': broadcasterId,
'moderator_id': moderatorId,
'user_id': userId,
},
);
return true;
}
}
/// Result of a successful Twitch `/oauth2/validate` call.
class TwitchTokenInfo {
final List<String> scopes;
/// Present for user access tokens; omitted for app access tokens.
final String? userId;
const TwitchTokenInfo({required this.scopes, this.userId});
}

View file

@ -0,0 +1,559 @@
import 'dart:math';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/apis/twitch_integrity.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/models/playback_access_token.dart';
import 'package:frosty/models/stream.dart';
/// Viewer sort for GQL directory / tag stream lists.
enum StreamViewerSort {
viewersDesc,
viewersAsc;
String get gqlValue => switch (this) {
StreamViewerSort.viewersDesc => 'VIEWER_COUNT',
StreamViewerSort.viewersAsc => 'VIEWER_COUNT_ASC',
};
}
/// Twitch GQL API client for playback tokens and directory queries
/// (freeform tags + sorted streams) that Helix does not expose.
///
/// Uses the Twitch web client ID (not the app's registered client ID)
/// since the GQL endpoint requires it.
class TwitchGqlApi extends BaseApiClient {
TwitchGqlApi(Dio dio) : super(dio, 'https://gql.twitch.tv');
static const _gqlHeaders = <String, String>{
'Client-ID': twitchGqlClientId,
'Content-Type': 'application/json',
};
Future<JsonMap> _gql(
Map<String, Object?> body, {
Map<String, String>? extraHeaders,
}) async {
final response = await post<JsonMap>(
'/gql',
data: body,
headers: {..._gqlHeaders, ...?extraHeaders},
);
final errors = response['errors'];
if (errors is List && errors.isNotEmpty) {
final message = (errors.first as JsonMap)['message'] as String? ??
'Twitch GQL error';
if (message.toLowerCase().contains('integrity')) {
throw IntegrityCheckException(message);
}
final data = response['data'];
final dataEmpty = data == null ||
(data is Map &&
data.values.every((value) => value == null));
if (dataEmpty) {
throw ApiException(message);
}
}
return response;
}
/// Optional web-session headers for directory pagination (page-2+).
Map<String, String> _directoryHeaders({
String? authToken,
String? integrityToken,
String? deviceId,
}) {
return {
'User-Agent': playbackUserAgent,
if (authToken != null && authToken.isNotEmpty)
'Authorization': 'OAuth $authToken',
if (deviceId != null && deviceId.isNotEmpty) 'X-Device-Id': deviceId,
if (integrityToken != null && integrityToken.isNotEmpty)
'Client-Integrity': integrityToken,
};
}
/// Browser-consistent User-Agent for playback-related requests. Twitch has
/// rejected access-token requests with non-browser UAs before
/// (streamlink#6574); sending one consistent with the web client ID and
/// web session token avoids the obvious fingerprint mismatch.
static const playbackUserAgent =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
/// Headers for the HLS playlist/segment requests issued by the native
/// player, mirroring what the web player sends to Usher/weaver.
static const playbackHttpHeaders = <String, String>{
'User-Agent': playbackUserAgent,
'Referer': 'https://player.twitch.tv',
'Origin': 'https://player.twitch.tv',
};
/// Fetches a playback access token for the given channel [login].
///
/// When [authToken] is provided, the request is authenticated which may
/// grant ad-free playback for subscribers and Turbo users.
///
/// [playerType] / [platform] mirror the web player's GQL variables. Ad
/// blocking (TwitchAdSolutions / vaft) uses non-`site` player types such as
/// `popout` / `embed` / `autoplay`.
///
/// Uses the web client's persisted GQL query (smaller payload, blends in
/// with regular web traffic); falls back to the inline query template if
/// Twitch ever rotates the persisted-query hash.
Future<PlaybackAccessToken> getPlaybackAccessToken({
required String login,
String? authToken,
String playerType = 'site',
String platform = 'web',
}) async {
final headers = <String, String>{
'Client-ID': twitchGqlClientId,
'User-Agent': playbackUserAgent,
if (authToken != null) 'Authorization': 'OAuth $authToken',
};
final variables = {
'isLive': true,
'login': login,
'isVod': false,
'vodID': '',
'playerType': playerType,
'platform': platform,
};
Future<JsonMap> query(Map<String, Object> body) =>
post<JsonMap>('/gql', data: body, headers: headers);
final response = await query({
'operationName': 'PlaybackAccessToken',
'extensions': {
'persistedQuery': {
'version': 1,
// Stable for years; used by the web player and streamlink.
'sha256Hash':
'ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9',
},
},
'variables': variables,
});
// PersistedQueryNotFound (or similar) comes back as a 200 with an
// errors array — retry once with the inline template before failing.
if (response['errors'] != null && response['data'] == null) {
debugPrint(
'TwitchGqlApi: persisted query failed (${response['errors']}), '
'falling back to inline template',
);
return PlaybackAccessToken.fromGqlResponse(
await query({
'operationName': 'PlaybackAccessToken_Template',
'query':
'query PlaybackAccessToken_Template(\$login: String!, \$isLive: Boolean!, \$vodID: ID!, \$isVod: Boolean!, \$playerType: String!) { streamPlaybackAccessToken(channelName: \$login, params: {platform: "web", playerBackend: "mediaplayer", playerType: \$playerType}) @include(if: \$isLive) { value signature __typename } videoPlaybackAccessToken(id: \$vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: \$playerType}) @include(if: \$isVod) { value signature __typename } }',
'variables': variables,
}),
);
}
return PlaybackAccessToken.fromGqlResponse(response);
}
/// Fetches raw playlist text (multivariant or media) with playback headers.
Future<String> fetchPlaylistText(String url) async {
return get<String>(url, headers: playbackHttpHeaders);
}
/// Builds the HLS stream URL for the given channel [login] and [token].
String buildHlsUrl({
required String login,
required PlaybackAccessToken token,
}) {
final random = Random().nextInt(999999);
final encodedToken = Uri.encodeComponent(token.value);
// Usher v2 — what the web player and streamlink use today. Same query
// params and token; the multivariant playlist is standards-compliant
// (EXT-X-SESSION-DATA + STREAM-INF only, no EXT-X-MEDIA/TWITCH-INFO).
return 'https://usher.ttvnw.net/api/v2/channel/hls/$login.m3u8'
'?sig=${token.signature}'
'&token=$encodedToken'
'&allow_source=true'
'&allow_audio_only=true'
'&fast_bread=true'
'&supported_codecs=av1,h265,h264'
'&reassignments_supported=true'
'&platform=web'
'&player_backend=mediaplayer'
'&play_session_id=${_randomSessionId()}'
'&playlist_include_framerate=true'
'&p=$random';
}
/// 32-char hex play-session id, matching the web client and streamlink. Sent
/// to Usher to identify the playback session; Usher tolerates its absence
/// today but has historically tightened required params.
String _randomSessionId() {
final random = Random();
final buffer = StringBuffer();
for (var i = 0; i < 32; i++) {
buffer.write(random.nextInt(16).toRadixString(16));
}
return buffer.toString();
}
/// Freeform tag name suggestions for search-as-you-type.
Future<List<String>> searchFreeformTags({
required String query,
int first = 20,
}) async {
if (query.trim().isEmpty) return const [];
final response = await _gql({
'operationName': 'SearchFreeformTags',
'query':
r'query SearchFreeformTags($query: String!, $first: Int) {'
r' searchFreeformTags(userQuery: $query, first: $first) {'
r' edges { node { tagName } } } }',
'variables': {'query': query, 'first': first},
});
final edges =
(((response['data'] as JsonMap?)?['searchFreeformTags']
as JsonMap?)?['edges']
as List?) ??
const [];
return edges
.map((edge) {
final node = (edge as JsonMap)['node'] as JsonMap?;
return node?['tagName'] as String?;
})
.whereType<String>()
.toList();
}
/// Live streams matching optional [freeformTags] / [languages], sorted by viewers.
///
/// Twitch caps `first` at 30 for this connection. Cursor pagination needs a
/// Client-Integrity token (+ matching [deviceId] / web [authToken]); without
/// them page-2 throws [IntegrityCheckException].
Future<StreamsTwitch> getStreams({
List<String>? freeformTags,
List<String>? languages,
StreamViewerSort sort = StreamViewerSort.viewersDesc,
String? cursor,
int first = 30,
String? authToken,
String? integrityToken,
String? deviceId,
}) async {
final cappedFirst = first.clamp(1, 30);
final gqlLangs = languages == null || languages.isEmpty
? null
: languages
.map((l) => l.trim().toUpperCase())
.where((l) => l.isNotEmpty)
.toList();
final response = await _gql(
{
'operationName': 'FrostyTopStreams',
'query':
r'query FrostyTopStreams($first: Int, $after: Cursor, $tags: [String!], $sort: StreamSort, $langs: [Language!]) {'
r' streams(first: $first, after: $after, options: { sort: $sort, freeformTags: $tags, languages: $langs }) {'
r' edges { cursor node {'
r' id title viewersCount createdAt language'
r' game { id name }'
r' broadcaster { id login displayName }'
r' } } pageInfo { hasNextPage } } }',
'variables': {
'first': cappedFirst,
'after': cursor,
'tags': freeformTags,
'sort': sort.gqlValue,
'langs': gqlLangs,
},
},
extraHeaders: _directoryHeaders(
authToken: authToken,
integrityToken: integrityToken,
deviceId: deviceId,
),
);
final connection =
(response['data'] as JsonMap?)?['streams'] as JsonMap? ?? const {};
return _streamsFromConnection(connection);
}
/// Live streams under a category/game, with viewer sort and optional languages.
Future<StreamsTwitch> getGameStreams({
required String gameId,
List<String>? languages,
StreamViewerSort sort = StreamViewerSort.viewersDesc,
String? cursor,
int first = 30,
String? authToken,
String? integrityToken,
String? deviceId,
}) async {
final cappedFirst = first.clamp(1, 30);
final gqlLangs = languages == null || languages.isEmpty
? null
: languages
.map((l) => l.trim().toUpperCase())
.where((l) => l.isNotEmpty)
.toList();
final response = await _gql(
{
'operationName': 'FrostyGameStreams',
'query':
r'query FrostyGameStreams($id: ID!, $first: Int, $after: Cursor, $sort: StreamSort, $langs: [Language!]) {'
r' game(id: $id) { streams(first: $first, after: $after, options: { sort: $sort, languages: $langs }) {'
r' edges { cursor node {'
r' id title viewersCount createdAt language'
r' game { id name }'
r' broadcaster { id login displayName }'
r' } } pageInfo { hasNextPage } } } }',
'variables': {
'id': gameId,
'first': cappedFirst,
'after': cursor,
'sort': sort.gqlValue,
'langs': gqlLangs,
},
},
extraHeaders: _directoryHeaders(
authToken: authToken,
integrityToken: integrityToken,
deviceId: deviceId,
),
);
final game = (response['data'] as JsonMap?)?['game'] as JsonMap?;
if (game == null) {
return const StreamsTwitch([], {});
}
return _streamsFromConnection(game['streams'] as JsonMap? ?? const {});
}
/// Persisted hashes used by the Twitch web Follow button (same as Xtra).
static const _followUserHash =
'800e7346bdf7e5278a3c1d3f21b2b56e2639928f86815677a7126b093b2fdd08';
static const _unfollowUserHash =
'f7dae976ebf41c755ae2d758546bfd176b4eeb856656098bb40e0a672ca0d880';
/// Follows [targetId] using the web GQL session. Helix no longer supports this.
///
/// Requires [authToken] from the Twitch web cookie (`gqlToken`). Some accounts
/// need a [integrityToken] + matching [deviceId]; callers should retry after
/// [fetchIntegrityToken] when [IntegrityCheckException] is thrown.
Future<void> followUser({
required String targetId,
required String authToken,
bool disableNotifications = false,
String? integrityToken,
String? deviceId,
}) async {
await _mutateFollow(
operationName: 'FollowButton_FollowUser',
sha256Hash: _followUserHash,
authToken: authToken,
integrityToken: integrityToken,
deviceId: deviceId,
variables: {
'input': {
'disableNotifications': disableNotifications,
'targetID': targetId,
},
},
successPath: const ['followUser'],
);
}
/// Unfollows [targetId] using the web GQL session.
Future<void> unfollowUser({
required String targetId,
required String authToken,
String? integrityToken,
String? deviceId,
}) async {
await _mutateFollow(
operationName: 'FollowButton_UnfollowUser',
sha256Hash: _unfollowUserHash,
authToken: authToken,
integrityToken: integrityToken,
deviceId: deviceId,
variables: {
'input': {'targetID': targetId},
},
successPath: const ['unfollowUser'],
);
}
/// Mints a Client-Integrity token for authenticated GQL mutations.
///
/// Bound to [deviceId] and [authToken]; send the same values on follow calls.
/// Tokens minted outside a real browser are usually flagged `is_bad_bot` —
/// those throw [IntegrityCheckException] so callers can fall back to a
/// WebView capture.
Future<({String token, int expirationMs})> fetchIntegrityToken({
required String authToken,
required String deviceId,
}) async {
final response = await post<JsonMap>(
'/integrity',
data: <String, dynamic>{},
headers: {
'Client-ID': twitchGqlClientId,
'Authorization': 'OAuth $authToken',
'X-Device-Id': deviceId,
'User-Agent': playbackUserAgent,
'Content-Type': 'application/json',
},
);
final token = response['token'] as String?;
final expiration = response['expiration'];
if (token == null || token.isEmpty) {
throw const ApiException('Twitch integrity token missing from response');
}
if (TwitchIntegrity.isBadBot(token)) {
throw const IntegrityCheckException(
'Twitch integrity token flagged as bad bot',
);
}
final expirationMs = expiration is int
? expiration
: int.tryParse('$expiration') ??
TwitchIntegrity.expirationMs(token) ??
0;
return (token: token, expirationMs: expirationMs);
}
Future<void> _mutateFollow({
required String operationName,
required String sha256Hash,
required String authToken,
required JsonMap variables,
required List<String> successPath,
String? integrityToken,
String? deviceId,
}) async {
final headers = <String, String>{
'Client-ID': twitchGqlClientId,
'Content-Type': 'application/json',
'Authorization': 'OAuth $authToken',
'User-Agent': playbackUserAgent,
if (deviceId != null && deviceId.isNotEmpty) 'X-Device-Id': deviceId,
if (integrityToken != null && integrityToken.isNotEmpty)
'Client-Integrity': integrityToken,
};
final response = await post<dynamic>(
'/gql',
data: {
'operationName': operationName,
'variables': variables,
'extensions': {
'persistedQuery': {
'version': 1,
'sha256Hash': sha256Hash,
},
},
},
headers: headers,
);
final map = _gqlPayloadMap(response);
_throwIfGqlFailed(map);
JsonMap? cursor = map['data'] as JsonMap?;
for (final key in successPath) {
final next = cursor?[key];
if (next is JsonMap) {
cursor = next;
} else if (next == null) {
throw ApiException('Twitch $operationName returned no data');
} else {
return;
}
}
}
JsonMap _gqlPayloadMap(dynamic response) {
if (response is List) {
if (response.isEmpty || response.first is! Map) {
throw const ApiException('Unexpected Twitch GQL response');
}
return Map<String, dynamic>.from(response.first as Map);
}
if (response is Map) {
return Map<String, dynamic>.from(response);
}
throw const ApiException('Unexpected Twitch GQL response');
}
void _throwIfGqlFailed(JsonMap map) {
final errors = map['errors'];
if (errors is! List || errors.isEmpty) return;
final message =
(errors.first as JsonMap)['message'] as String? ?? 'Twitch GQL error';
final lower = message.toLowerCase();
if (lower.contains('integrity')) {
throw IntegrityCheckException(message);
}
throw ApiException(message);
}
StreamsTwitch _streamsFromConnection(JsonMap connection) {
final edges = (connection['edges'] as List?) ?? const [];
final pageInfo = connection['pageInfo'] as JsonMap?;
// Prefer explicit pageInfo; if missing, assume more when we got a full page.
final hasNext = pageInfo?['hasNextPage'] as bool? ?? edges.length >= 30;
final streams = <StreamTwitch>[];
String? nextCursor;
for (final edge in edges) {
final edgeMap = edge as JsonMap;
nextCursor = edgeMap['cursor'] as String?;
final node = edgeMap['node'] as JsonMap?;
if (node == null) continue;
streams.add(_streamFromNode(node));
}
return StreamsTwitch(
streams,
hasNext && nextCursor != null && nextCursor.isNotEmpty
? {'cursor': nextCursor}
: const {},
);
}
StreamTwitch _streamFromNode(JsonMap node) {
final broadcaster = node['broadcaster'] as JsonMap? ?? const {};
final game = node['game'] as JsonMap? ?? const {};
final login = broadcaster['login'] as String? ?? '';
final language = (node['language'] as String? ?? '').toLowerCase();
return StreamTwitch(
broadcaster['id'] as String? ?? '',
login,
broadcaster['displayName'] as String? ?? login,
game['id'] as String? ?? '',
game['name'] as String? ?? '',
node['title'] as String? ?? '',
(node['viewersCount'] as num?)?.toInt() ?? 0,
node['createdAt'] as String? ?? '',
// Helix-style placeholder so existing thumbnail widgets keep working.
'https://static-cdn.jtvnw.net/previews-ttv/live_user_$login-{width}x{height}.jpg',
language,
const [],
);
}
}

View file

@ -0,0 +1,81 @@
import 'dart:convert';
/// Helpers for Twitch `Client-Integrity` tokens (`v4.public.*` / `v4.local.*`).
class TwitchIntegrity {
const TwitchIntegrity._();
/// Decodes the JSON payload embedded in an integrity token, if present.
static Map<String, dynamic>? decodePayload(String token) {
final parts = token.split('.');
if (parts.length < 2) return null;
for (final part in parts.skip(1)) {
if (part.isEmpty) continue;
try {
final normalized = base64Url.normalize(part);
final decoded = utf8.decode(base64Url.decode(normalized));
final dynamic json = jsonDecode(decoded);
if (json is Map &&
(json.containsKey('is_bad_bot') ||
json.containsKey('client_id') ||
json.containsKey('device_id'))) {
return Map<String, dynamic>.from(json);
}
} catch (_) {
// Try the next segment — token layout varies by version.
}
}
return null;
}
/// Dio/`POST /integrity` tokens from non-browser clients are usually flagged.
static bool isBadBot(String token) {
final payload = decodePayload(token);
if (payload == null) return false;
final flag = payload['is_bad_bot'];
if (flag is bool) return flag;
if (flag is String) return flag.toLowerCase() == 'true';
return false;
}
/// Expiration in epoch millis from the payload, if present.
static int? expirationMs(String token) {
final payload = decodePayload(token);
if (payload == null) return null;
final exp = payload['exp'];
if (exp is int) {
// Already millis if large; seconds if typical JWT.
return exp > 100000000000 ? exp : exp * 1000;
}
if (exp is String) {
final asInt = int.tryParse(exp);
if (asInt != null) {
return asInt > 100000000000 ? asInt : asInt * 1000;
}
final dt = DateTime.tryParse(exp);
if (dt != null) return dt.millisecondsSinceEpoch;
}
return null;
}
/// Device id the token was minted for (`X-Device-Id` must match).
static String? deviceId(String token) {
final payload = decodePayload(token);
final id = payload?['device_id'];
return id is String && id.isNotEmpty ? id : null;
}
}
/// Credentials captured from a browser/WebView GQL request.
class GqlIntegrityCredentials {
final String integrityToken;
final String deviceId;
final int expirationMs;
const GqlIntegrityCredentials({
required this.integrityToken,
required this.deviceId,
required this.expirationMs,
});
}

View file

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:frosty/main.dart';
import 'package:frosty/screens/onboarding/login_webview.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/utils/twitch_oauth_scopes.dart';
import 'package:frosty/widgets/frosty_dialog.dart';
import 'package:provider/provider.dart';
@ -14,16 +15,67 @@ class UnauthorizedInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
// Check if this is a 401 Unauthorized error
if (err.response?.statusCode == 401) {
// Check if this is a 401 Unauthorized error on a Twitch Helix/OAuth request
if (err.response?.statusCode == 401 &&
_isTwitchHelixOrOAuth(err.requestOptions.uri)) {
// For token validation requests, let the error propagate so validateToken can handle it
if (err.requestOptions.uri.path.endsWith('/validate')) {
handler.next(err);
return;
}
// For other requests, show login dialog and don't propagate
_showLoginDialog();
final path = err.requestOptions.uri.path;
// Background moderated-channels fetches must never nag — especially for
// core-only tokens after an app upgrade.
if (isTwitchModeratedChannelsPath(path)) {
handler.reject(err);
return;
}
// Moderator actions without scopes → opt-in Enable flow, not a generic
// "missing permissions" re-login for core scopes.
if (isTwitchModeratorActionPath(path) &&
!_authStore.hasModeratorScopes) {
_showAuthDialog(
title: 'Enable moderator tools',
message: enableModeratorToolsDialogMessage(),
primaryLabel: 'Enable',
onPrimary: () => openEnableModeratorTools(_authStore),
);
handler.reject(err);
return;
}
// Core-only messaging: if core scopes are present, prefer "Session expired"
// over "Missing permissions" (401 ≠ always missing scope).
final isLoggedIn = _authStore.isLoggedIn;
final missingCore = missingTwitchScopes(
granted: _authStore.grantedScopes,
);
_showAuthDialog(
title: isLoggedIn && missingCore.isNotEmpty
? 'Missing permissions'
: 'Session expired',
message: unauthorizedDialogMessage(
isLoggedIn: isLoggedIn,
grantedScopes: _authStore.grantedScopes,
),
primaryLabel: 'Log in',
onPrimary: () {
final context = navigatorKey.currentContext;
if (context == null) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => Provider<AuthStore>.value(
value: _authStore,
child: const LoginWebView(),
),
),
);
},
);
handler.reject(err);
return;
}
@ -31,25 +83,20 @@ class UnauthorizedInterceptor extends Interceptor {
handler.next(err);
}
void _showLoginDialog() {
// Use the global navigator key to show dialog without needing BuildContext
void _showAuthDialog({
required String title,
required String message,
required String primaryLabel,
required VoidCallback onPrimary,
}) {
final context = navigatorKey.currentContext;
if (context == null) return;
// Use a flag to prevent multiple dialogs
if (_isDialogShowing) return;
_isDialogShowing = true;
// Determine if user is logged in but missing scopes vs completely logged out
final isLoggedIn = _authStore.isLoggedIn;
final title = isLoggedIn ? 'Missing permissions' : 'Session expired';
final message = isLoggedIn
? 'Your session is missing permissions. Please log in again to continue.'
: 'Your session has expired. Please log in again to continue.';
showDialog(
context: context,
barrierDismissible: false, // User must choose an action
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return FrostyDialog(
title: title,
@ -58,34 +105,39 @@ class UnauthorizedInterceptor extends Interceptor {
TextButton(
onPressed: () {
_isDialogShowing = false;
Navigator.of(dialogContext).pop(); // Close dialog
Navigator.of(dialogContext).pop();
},
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
_isDialogShowing = false;
Navigator.of(dialogContext).pop(); // Close dialog
// Navigate to login WebView
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => Provider<AuthStore>.value(
value: _authStore,
child: const LoginWebView(),
),
),
);
Navigator.of(dialogContext).pop();
onPrimary();
},
child: Text('Log in'),
child: Text(primaryLabel),
),
],
);
},
).then((_) {
// Reset flag when dialog is dismissed
_isDialogShowing = false;
});
}
static bool _isDialogShowing = false;
bool _isTwitchHelixOrOAuth(Uri uri) {
final url = uri.toString();
return url.startsWith('https://api.twitch.tv/helix') ||
url.startsWith('https://id.twitch.tv/oauth2');
}
}
/// Helix path for listing channels the user moderates.
bool isTwitchModeratedChannelsPath(String path) =>
path.contains('/moderation/channels');
/// Helix paths for delete / ban / timeout / unban.
bool isTwitchModeratorActionPath(String path) =>
path.contains('/moderation/chat') || path.contains('/moderation/bans');

View file

@ -3,6 +3,18 @@ import 'package:flutter/material.dart';
/// Twitch API client ID.
const clientId = String.fromEnvironment('CLIENT_ID');
/// Twitch GQL client ID (web player, used for playback access tokens).
const twitchGqlClientId = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
/// SharedPreferences key for the per-channel last-selected stream quality.
/// A saved variant that doesn't exist on the target channel (e.g. 1080p on a
/// source-only channel) falls back to Auto via the quality-list lookup.
String lastStreamQualityKey(String userLogin) =>
'last_stream_quality_$userLogin';
/// Legacy global key. Cleared on startup during SharedPreferences init.
const kLegacyLastStreamQualityKey = 'last_stream_quality';
/// Twitch API client secret.
const secret = String.fromEnvironment('SECRET');
@ -42,6 +54,9 @@ final regexLink = RegExp(
caseSensitive: false,
);
/// Standard corner radius for card thumbnails and box art.
const kCardBorderRadius = BorderRadius.all(Radius.circular(8));
/// The default badge width and height.
const defaultBadgeSize = 18.0;

View file

@ -1,22 +1,30 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:advanced_in_app_review/advanced_in_app_review.dart';
import 'package:app_links/app_links.dart';
import 'package:dio/dio.dart';
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:firebase_performance/firebase_performance.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:frosty/apis/base_api_client.dart' show ApiException;
import 'package:frosty/apis/bttv_api.dart';
import 'package:frosty/apis/dio_client.dart';
import 'package:frosty/apis/ffz_api.dart';
import 'package:frosty/apis/seventv_api.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/apis/twitch_auth_interceptor.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:frosty/apis/unauthorized_interceptor.dart';
import 'package:frosty/cache_manager.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/firebase_options.dart';
import 'package:frosty/screens/channel/channel.dart';
import 'package:frosty/screens/home/home.dart';
@ -31,6 +39,25 @@ import 'package:mobx/mobx.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
/// Errors that are expected on a mobile client — transient network/I/O, plugin
/// teardown races, and image fetch failures — and therefore shouldn't be
/// reported to Crashlytics as fatal crashes (which would tank the crash-free
/// metric and bucket unrelated errors together under the global handlers).
///
/// This is a last-resort net. The layers that own these errors already record
/// them non-fatally with precise reasons (e.g. base_api_client, the IRC
/// websocket handlers); this only catches what escapes that local handling.
bool _isNonFatalError(Object error) {
return error is SocketException ||
error is HttpException ||
error is WebSocketChannelException ||
error is ApiException ||
error is MissingPluginException ||
error is NetworkImageLoadException ||
(error is DioException && error.type != DioExceptionType.badResponse);
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@ -39,17 +66,44 @@ void main() async {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
// Pass all uncaught "fatal" errors from the framework to Crashlytics
FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;
// Route uncaught framework errors to Crashlytics. Non-crashing UI errors
// (layout overflows, image-load failures) and other expected transient
// failures are recorded as non-fatal so they don't inflate the crash count.
FlutterError.onError = (details) {
// Run the cheap type checks first; only fall back to the diagnostics string
// (RenderFlex overflow has no typed signal) when those miss.
final isNonFatal =
_isNonFatalError(details.exception) ||
details.exceptionAsString().contains('overflowed');
if (isNonFatal) {
FirebaseCrashlytics.instance.recordFlutterError(details);
} else {
FirebaseCrashlytics.instance.recordFlutterFatalError(details);
}
};
// Pass all uncaught asynchronous errors that aren't handled by the Flutter framework to Crashlytics
// Pass uncaught asynchronous errors to Crashlytics. Expected transient errors
// are recorded non-fatally; everything else stays fatal. The reason gives the
// grouping engine a real signal so distinct errors don't collapse into one
// catch-all issue under this handler frame.
PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
FirebaseCrashlytics.instance.recordError(
error,
stack,
reason: error.runtimeType.toString(),
fatal: !_isNonFatalError(error),
);
return true;
};
final prefs = await SharedPreferences.getInstance();
// One-time migration: the quality preference used to be stored under a
// single global key, which meant a saved 1080p60 from one channel could
// pin another channel to a variant it doesn't serve. Remove the legacy
// key; new writes go under per-channel keys.
await prefs.remove(kLegacyLastStreamQualityKey);
final firstRun = prefs.getBool('first_run') ?? true;
// Workaround for clearing stored tokens on uninstall.
@ -77,6 +131,19 @@ void main() async {
// Initialize a settings store from the settings JSON string.
final settingsStore = SettingsStore.fromJson(jsonDecode(userSettings));
// Disable Firebase collection in debug builds to avoid polluting production data.
// In release builds, respect the user's preference.
if (kDebugMode) {
FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(false);
FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(false);
FirebasePerformance.instance.setPerformanceCollectionEnabled(false);
} else {
final shareEnabled = settingsStore.shareCrashLogsAndAnalytics;
FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(shareEnabled);
FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(shareEnabled);
FirebasePerformance.instance.setPerformanceCollectionEnabled(shareEnabled);
}
// Create a MobX reaction that will save the settings on disk every time they are changed.
autorun((_) => prefs.setString('settings', jsonEncode(settingsStore)));
@ -89,6 +156,7 @@ void main() async {
final bttvApiService = BTTVApi(dioClient);
final ffzApiService = FFZApi(dioClient);
final sevenTVApiService = SevenTVApi(dioClient);
final twitchGqlApiService = TwitchGqlApi(dioClient);
// Create global assets store (shared cache for global emotes/badges)
final globalAssetsStore = GlobalAssetsStore(
@ -99,7 +167,10 @@ void main() async {
);
// Create and initialize the authentication store
final authStore = AuthStore(twitchApi: twitchApiService);
final authStore = AuthStore(
twitchApi: twitchApiService,
twitchGqlApi: twitchGqlApiService,
);
// Add the auth interceptor to the Dio client after AuthStore creation
dioClient.interceptors.add(TwitchAuthInterceptor(authStore));
@ -108,6 +179,10 @@ void main() async {
dioClient.interceptors.add(UnauthorizedInterceptor(authStore));
await authStore.init();
FirebaseCrashlytics.instance.setCustomKey('is_logged_in', authStore.isLoggedIn);
if (authStore.isLoggedIn && authStore.user.details != null) {
FirebaseCrashlytics.instance.setUserIdentifier(authStore.user.details!.id);
}
runApp(
MultiProvider(
@ -118,6 +193,7 @@ void main() async {
Provider<BTTVApi>.value(value: bttvApiService),
Provider<FFZApi>.value(value: ffzApiService),
Provider<SevenTVApi>.value(value: sevenTVApiService),
Provider<TwitchGqlApi>.value(value: twitchGqlApiService),
Provider<GlobalAssetsStore>.value(value: globalAssetsStore),
],
child: MyApp(firstRun: firstRun),
@ -164,20 +240,22 @@ class _MyAppState extends State<MyApp> {
colorSchemeSeed: Color(settingsStore.accentColor),
);
return Provider<FrostyThemes>(
create: (_) => themes,
child: MaterialApp(
title: 'Frosty',
theme: themes.light,
darkTheme: themes.dark,
themeMode: settingsStore.themeType == ThemeType.system
? ThemeMode.system
: settingsStore.themeType == ThemeType.light
? ThemeMode.light
: ThemeMode.dark,
home: widget.firstRun ? const OnboardingIntro() : const Home(),
navigatorKey: navigatorKey,
),
return MaterialApp(
title: 'Frosty',
theme: themes.light,
darkTheme: themes.dark,
themeMode: settingsStore.themeType == ThemeType.system
? ThemeMode.system
: settingsStore.themeType == ThemeType.light
? ThemeMode.light
: ThemeMode.dark,
navigatorObservers: [
FirebaseAnalyticsObserver(
analytics: FirebaseAnalytics.instance,
),
],
home: widget.firstRun ? const OnboardingIntro() : const Home(),
navigatorKey: navigatorKey,
);
},
);
@ -230,6 +308,7 @@ class _MyAppState extends State<MyApp> {
final user = await twitchApi.getUser(userLogin: channelName);
final route = MaterialPageRoute(
settings: const RouteSettings(name: VideoChat.routeName),
builder: (context) => VideoChat(
userId: user.id,
userName: user.displayName,

View file

@ -56,6 +56,11 @@ class IRCMessage {
this.actionLabel,
});
/// The channel this message actually originated from. In Twitch shared
/// chat, `source-room-id` names the participant channel a message was
/// sent in; otherwise it's just this connection's own room.
String? get sourceChannelId => tags['source-room-id'] ?? tags['room-id'];
/// Applies the given CLEARCHAT message to the provided messages and buffer.
static void clearChat({
required List<IRCMessage> messages,
@ -176,7 +181,9 @@ class IRCMessage {
size: badgeSize,
color:
Theme.of(context).iconTheme.color?.withValues(alpha: 0.5) ??
Colors.grey.withValues(alpha: 0.5),
Theme.of(context).colorScheme.onSurfaceVariant.withValues(
alpha: 0.5,
),
),
),
),
@ -184,7 +191,6 @@ class IRCMessage {
span.add(const TextSpan(text: ' '));
}
final sourceChannelId = tags['source-room-id'] ?? tags['room-id'];
final sourceChannelUser = channelIdToUserTwitch?[sourceChannelId];
if (sourceChannelUser != null) {
final isCurrentChannel = sourceChannelId == currentChannelId;
@ -442,8 +448,9 @@ class IRCMessage {
bool launchExternal,
TextStyle? textStyle,
void Function(String)? onTapPingedUser,
void Function()? onTapDeletedMessage,
) {
void Function()? onTapDeletedMessage, {
Color? linkColor,
}) {
if (!showMessage) {
span.add(
TextSpan(
@ -493,6 +500,7 @@ class IRCMessage {
emoteScale,
textStyle,
launchExternal,
linkColor: linkColor,
);
} else {
localSpan.add(
@ -524,6 +532,7 @@ class IRCMessage {
_createTextSpan(
text: word,
style: textStyle,
linkColor: linkColor,
launchExternal: launchExternal,
onTapPingedUser: onTapPingedUser,
),
@ -550,8 +559,9 @@ class IRCMessage {
double emoteSize,
double emoteScale,
TextStyle? textStyle,
bool launchExternal,
) {
bool launchExternal, {
Color? linkColor,
}) {
final emoteStack = <Emote>[];
var index = startIndex;
@ -631,6 +641,7 @@ class IRCMessage {
_createTextSpan(
text: words[index],
style: textStyle,
linkColor: linkColor,
launchExternal: launchExternal,
),
);
@ -656,16 +667,31 @@ class IRCMessage {
bool showMessage = true,
Map<String, UserTwitch>? channelIdToUserTwitch,
TimestampType timestamp = TimestampType.disabled,
bool forceTimestamp = false,
bool showHistoricalTimestamps = false,
String? currentChannelId,
}) {
final emoteToObject = assetsStore.emoteToObject;
final badgeSize = defaultBadgeSize * badgeScale;
final emoteSize = defaultEmoteSize * emoteScale;
final linkColor = Theme.of(context).colorScheme.primary;
// The span list that will be used to render the chat message
final span = <InlineSpan>[];
_addTimestamp(span, style, timestamp);
// When the user has timestamps disabled globally, fall back to the device's
// locale preference for historical messages (opt-in via setting) and for
// forced contexts like previews.
final isHistorical = tags['historical'] == '1';
final effectiveTimestamp =
(timestamp == TimestampType.disabled &&
((isHistorical && showHistoricalTimestamps) || forceTimestamp))
? (MediaQuery.alwaysUse24HourFormatOf(context)
? TimestampType.twentyFour
: TimestampType.twelve)
: timestamp;
_addTimestamp(span, style, effectiveTimestamp);
_addHistoricalAndChannelBadges(
context,
span,
@ -674,7 +700,6 @@ class IRCMessage {
currentChannelId,
);
final isHistorical = tags['historical'] == '1';
_addUserBadges(
context,
span,
@ -702,6 +727,7 @@ class IRCMessage {
textStyle,
onTapPingedUser,
onTapDeletedMessage,
linkColor: linkColor,
);
return span;
@ -810,6 +836,7 @@ class IRCMessage {
required String text,
required bool launchExternal,
TextStyle? style,
Color? linkColor,
Function(String)? onTapPingedUser,
}) {
if (text.startsWith('@')) {
@ -827,9 +854,9 @@ class IRCMessage {
return TextSpan(
text: text,
style: style?.copyWith(
color: Colors.blue,
color: linkColor,
decoration: TextDecoration.underline,
decorationColor: Colors.blue,
decorationColor: linkColor,
),
recognizer: TapGestureRecognizer()
..onTap = () => launchUrl(
@ -951,22 +978,31 @@ class IRCMessage {
// Obtain the index to break off the tags.
final tagAndIrcMessageDivider = whole.indexOf(' ');
// Get the tags substring and escape characters.
// IRC messages escape spaces with \s.
final tags = whole
.substring(1, tagAndIrcMessageDivider)
.replaceAll('\\s', ' ');
// Without a separating space there are no tags to slice and no message body
// to parse — bail out rather than throw on the substring/index calls below.
if (tagAndIrcMessageDivider == -1) {
return IRCMessage(raw: whole, command: Command.none, tags: const {});
}
// Next, parse and map the tags.
// Parse the IRCv3 tags into a map. The tags section is everything between
// the leading '@' and the first space. We split on the *raw* string: a
// literal ';' is always a separator because escaped semicolons inside a
// value are encoded as `\:` (unescaped per-value below).
final mappedTags = <String, String>{};
final rawTags = whole.substring(1, tagAndIrcMessageDivider);
// Loop through each tag and store their key value pairs into the map.
for (final tag in tags.split(';')) {
// Skip if the tag has no value.
if (tag.endsWith('=')) continue;
for (final tag in rawTags.split(';')) {
// Split on the first '=' only — IRCv3 tag values may contain '='.
final equalsIndex = tag.indexOf('=');
final tagSplit = tag.split('=');
mappedTags[tagSplit[0]] = tagSplit[1];
// Skip valueless tags. Per the IRCv3 spec a missing value (bare `key`)
// and an empty value (`key=`) are equivalent — live Twitch sends the
// latter, the recent-messages API the former.
if (equalsIndex == -1 || equalsIndex == tag.length - 1) continue;
mappedTags[tag.substring(0, equalsIndex)] = _unescapeTagValue(
tag.substring(equalsIndex + 1),
);
}
// Now we'll parse the message itself.
@ -980,11 +1016,29 @@ class IRCMessage {
// Index 3 and beyond is the :message (word by word) that is sent by the user or empty depending on the command
final splitMessage = ircMessage.split(' ');
// A well-formed message is at least `<prefix> <COMMAND>`. A malformed or
// truncated line (e.g. from the recent-messages history API) can split into
// a single token, which would otherwise throw a RangeError on the
// splitMessage[0]/[1] accesses below.
if (splitMessage.length < 2) {
return IRCMessage(raw: whole, command: Command.none, tags: mappedTags);
}
// If the username exists, set it.
// tmi.twitch.tv means the message was sent by Twitch rather than a user, so will be irrelevant.
final String? user = splitMessage[0] == _twitchIrcServer
? mappedTags['login']
: splitMessage[0].substring(0, splitMessage[0].indexOf('!'));
// tmi.twitch.tv means the message was sent by Twitch rather than a user, so it is irrelevant.
final prefix = splitMessage[0];
final String? user;
if (prefix == _twitchIrcServer) {
user = mappedTags['login'];
} else {
// Normal prefixes are `nick!user@host`. Fall back to the login tag (or
// null) if the expected `!` is absent so an unexpected prefix shape can
// never throw a RangeError and take down the whole message.
final bangIndex = prefix.indexOf('!');
user = bangIndex == -1
? mappedTags['login']
: prefix.substring(0, bangIndex);
}
// If there is an associated message, set it.
String? message;
@ -1015,9 +1069,9 @@ class IRCMessage {
indexBetweenIdAndPositions,
);
// Parse the range in order to extract the associated word.
// If there are more than one indices, use the first one.
// Else, use the one provided indices.
// Parse the range to extract the associated word.
// If there is more than one index, use the first.
// Otherwise, use the provided index.
final String range;
if (emoteIdAndPosition.contains(',')) {
range = emoteIdAndPosition.substring(
@ -1145,13 +1199,59 @@ class IRCMessage {
);
}
/// Unescapes an IRCv3 message-tag value.
///
/// The spec defines these sequences: `\:` → `;`, `\s` → space, `\\` → `\`,
/// `\r` → CR, `\n` → LF. Any other `\x` collapses to `x`, and a lone trailing
/// backslash is dropped. Done in a single pass so sequences can't be
/// double-unescaped (e.g. `\\s` correctly yields `\s`, not a space).
static String _unescapeTagValue(String value) {
// Fast path: the vast majority of tag values contain no escape sequences.
if (!value.contains('\\')) return value;
final buffer = StringBuffer();
for (var i = 0; i < value.length; i++) {
if (value[i] != '\\') {
buffer.write(value[i]);
continue;
}
// A trailing backslash with nothing after it is dropped.
if (i + 1 == value.length) break;
switch (value[i + 1]) {
case ':':
buffer.write(';');
break;
case 's':
buffer.write(' ');
break;
case '\\':
buffer.write('\\');
break;
case 'r':
buffer.write('\r');
break;
case 'n':
buffer.write('\n');
break;
default:
buffer.write(value[i + 1]);
}
i++; // Skip the escaped character we just consumed.
}
return buffer.toString();
}
factory IRCMessage.createNotice({
required String message,
VoidCallback? actionCallback,
String? actionLabel,
}) => IRCMessage(
raw: '',
tags: {},
tags: {
'tmi-sent-ts': '${DateTime.now().millisecondsSinceEpoch}',
},
command: Command.notice,
message: message,
actionCallback: actionCallback,
@ -1204,8 +1304,9 @@ class USERSTATE {
raw: ircMessage.raw,
color: ircMessage.tags['color'] ?? color,
displayName: ircMessage.tags['display-name'] ?? displayName,
mod: ircMessage.tags['mod'] == '0' ? false : true,
subscriber: ircMessage.tags['subscriber'] == '0' ? false : true,
// Missing tags must be false — the old `!= '0'` default treated absence as mod.
mod: ircMessage.tags['mod'] == '1',
subscriber: ircMessage.tags['subscriber'] == '1',
);
}

View 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,
);
}
}

View file

@ -15,6 +15,14 @@ class StreamTwitch {
final String startedAt;
final String thumbnailUrl;
/// ISO 639-1 broadcast language from Helix (`de`, `en`, …). Empty when unknown.
@JsonKey(defaultValue: '')
final String language;
/// Freeform tags from Helix (e.g. `Deutsch`, `English`).
@JsonKey(defaultValue: <String>[])
final List<String> tags;
const StreamTwitch(
this.userId,
this.userLogin,
@ -24,8 +32,10 @@ class StreamTwitch {
this.title,
this.viewerCount,
this.startedAt,
this.thumbnailUrl,
);
this.thumbnailUrl, [
this.language = '',
this.tags = const [],
]);
factory StreamTwitch.fromJson(Map<String, dynamic> json) =>
_$StreamTwitchFromJson(json);
@ -38,6 +48,20 @@ class StreamsTwitch {
const StreamsTwitch(this.data, this.pagination);
factory StreamsTwitch.fromJson(Map<String, dynamic> json) =>
_$StreamsTwitchFromJson(json);
factory StreamsTwitch.fromJson(Map<String, dynamic> json) {
final rawPagination = json['pagination'];
final pagination = <String, String>{};
if (rawPagination is Map) {
final cursor = rawPagination['cursor'];
if (cursor is String && cursor.isNotEmpty) {
pagination['cursor'] = cursor;
}
}
return StreamsTwitch(
(json['data'] as List<dynamic>)
.map((e) => StreamTwitch.fromJson(e as Map<String, dynamic>))
.toList(),
pagination,
);
}
}

View file

@ -16,6 +16,8 @@ StreamTwitch _$StreamTwitchFromJson(Map<String, dynamic> json) => StreamTwitch(
(json['viewer_count'] as num).toInt(),
json['started_at'] as String,
json['thumbnail_url'] as String,
json['language'] as String? ?? '',
(json['tags'] as List<dynamic>?)?.map((e) => e as String).toList() ?? [],
);
StreamsTwitch _$StreamsTwitchFromJson(Map<String, dynamic> json) =>

View file

@ -0,0 +1,50 @@
/// ISO 639-1 codes offered in Settings for stream language filtering.
///
/// Stored lowercase (Helix). GQL `Language` enums are the uppercase form.
class StreamLanguages {
StreamLanguages._();
static const all = <({String code, String label})>[
(code: 'de', label: 'Deutsch'),
(code: 'en', label: 'English'),
(code: 'es', label: 'Español'),
(code: 'fr', label: 'Français'),
(code: 'it', label: 'Italiano'),
(code: 'pt', label: 'Português'),
(code: 'pl', label: 'Polski'),
(code: 'ru', label: 'Русский'),
(code: 'tr', label: 'Türkçe'),
(code: 'nl', label: 'Nederlands'),
(code: 'sv', label: 'Svenska'),
(code: 'no', label: 'Norsk'),
(code: 'da', label: 'Dansk'),
(code: 'fi', label: 'Suomi'),
(code: 'cs', label: 'Čeština'),
(code: 'hu', label: 'Magyar'),
(code: 'ro', label: 'Română'),
(code: 'uk', label: 'Українська'),
(code: 'ja', label: '日本語'),
(code: 'ko', label: '한국어'),
(code: 'zh', label: '中文'),
(code: 'ar', label: 'العربية'),
(code: 'th', label: 'ไทย'),
(code: 'vi', label: 'Tiếng Việt'),
(code: 'id', label: 'Bahasa Indonesia'),
(code: 'hi', label: 'हिन्दी'),
(code: 'other', label: 'Other'),
];
static String labelFor(String code) {
for (final entry in all) {
if (entry.code == code) return entry.label;
}
return code;
}
/// Helix / settings codes → GQL `Language` enum values.
static List<String> toGqlEnums(Iterable<String> codes) => codes
.map((c) => c.trim().toLowerCase())
.where((c) => c.isNotEmpty)
.map((c) => c.toUpperCase())
.toList();
}

View file

@ -6,9 +6,13 @@ import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart';
import 'package:frosty/screens/channel/video/channel_info_header.dart';
import 'package:frosty/screens/channel/video/native_video.dart';
import 'package:frosty/screens/channel/video/native_video_store.dart';
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
import 'package:frosty/screens/channel/video/video.dart';
import 'package:frosty/screens/channel/video/video_overlay.dart';
import 'package:frosty/screens/channel/video/video_player_interface.dart';
import 'package:frosty/screens/channel/video/video_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/theme.dart';
@ -16,12 +20,13 @@ import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/blurred_container.dart';
import 'package:frosty/widgets/draggable_divider.dart';
import 'package:frosty/widgets/frosty_notification.dart';
import 'package:provider/provider.dart';
import 'package:simple_pip_mode/actions/pip_actions_layout.dart';
import 'package:simple_pip_mode/pip_widget.dart';
/// Creates a widget that shows the video stream (if live) and chat of the given user.
class VideoChat extends StatefulWidget {
static const routeName = 'VideoChat';
final String userId;
final String userName;
final String userLogin;
@ -42,11 +47,10 @@ class _VideoChatState extends State<VideoChat>
final _videoKey = GlobalKey();
final _chatKey = GlobalKey();
// PiP drag state - essential only
double _pipDragDistance = 0;
// PiP drag state — ValueNotifier avoids full widget rebuilds during drag.
final _pipDragDistance = ValueNotifier<double>(0);
bool _isPipDragging = false;
bool _isInPipTriggerZone =
false; // Track when in trigger zone for haptic feedback
bool _isInPipTriggerZone = false;
// Divider drag state for synchronizing animation
bool _isDividerDragging = false;
@ -59,6 +63,8 @@ class _VideoChatState extends State<VideoChat>
late AnimationController _animationController;
late Animation<double> _springBackAnimation;
bool? _isImmersiveSystemUiEnabled;
late final ChatTabsStore _chatTabsStore = ChatTabsStore(
twitchApi: context.twitchApi,
bttvApi: context.bttvApi,
@ -72,13 +78,24 @@ class _VideoChatState extends State<VideoChat>
primaryDisplayName: widget.userName,
);
late final VideoStore _videoStore = VideoStore(
userLogin: widget.userLogin,
userId: widget.userId,
twitchApi: context.twitchApi,
authStore: context.authStore,
settingsStore: context.settingsStore,
);
late final VideoPlayerInterface _videoStore =
context.settingsStore.useNativePlayer
? NativeVideoStore(
userLogin: widget.userLogin,
userId: widget.userId,
displayName: widget.userName,
twitchApi: context.twitchApi,
twitchGqlApi: context.twitchGqlApi,
authStore: context.authStore,
settingsStore: context.settingsStore,
)
: VideoStore(
userLogin: widget.userLogin,
userId: widget.userId,
twitchApi: context.twitchApi,
authStore: context.authStore,
settingsStore: context.settingsStore,
);
@override
void initState() {
@ -95,12 +112,10 @@ class _VideoChatState extends State<VideoChat>
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
);
// Attach listener to the controller (not the derived animation) so it
// survives _springBackAnimation being reassigned in _animateSpringBack.
// Drive the ValueNotifier from the spring-back animation so the
// AnimatedBuilder rebuilds without a full setState.
_animationController.addListener(() {
setState(() {
_pipDragDistance = _springBackAnimation.value;
});
_pipDragDistance.value = _springBackAnimation.value;
});
// Register as observer for app lifecycle events
@ -117,58 +132,58 @@ class _VideoChatState extends State<VideoChat>
}
void _handlePipDragStart(DragStartDetails details) {
// Disable drag gesture when already in PiP mode or video is not playing
if (_videoStore.isInPipMode || _videoStore.paused) return;
if (_videoStore.isInPipMode || _videoStore.paused || _videoStore.loading) {
return;
}
_animationController.stop(); // Stop any ongoing animation
_animationController.stop();
_pipDragDistance.value = 0;
_isInPipTriggerZone = false;
setState(() {
_isPipDragging = true;
_pipDragDistance = 0;
_isInPipTriggerZone = false;
});
}
void _handlePipDragUpdate(DragUpdateDetails details) {
if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) {
if (!_isPipDragging ||
_videoStore.isInPipMode ||
_videoStore.paused ||
_videoStore.loading) {
return;
}
setState(() {
_pipDragDistance += details.delta.dy;
_pipDragDistance = _pipDragDistance.clamp(0, _pipMaxDragDistance);
// Update the ValueNotifier directly — only the ListenableBuilder rebuilds,
// not the entire channel scaffold.
_pipDragDistance.value =
(_pipDragDistance.value + details.delta.dy).clamp(0, _pipMaxDragDistance);
// Check if we've entered or exited the trigger zone for haptic feedback
final wasInTriggerZone = _isInPipTriggerZone;
_isInPipTriggerZone = _pipDragDistance >= _pipTriggerDistance;
final wasInTriggerZone = _isInPipTriggerZone;
_isInPipTriggerZone = _pipDragDistance.value >= _pipTriggerDistance;
// Provide haptic feedback when entering the trigger zone
if (!wasInTriggerZone && _isInPipTriggerZone) {
HapticFeedback.mediumImpact(); // Entering trigger zone
}
// Provide subtle haptic feedback when exiting the trigger zone
else if (wasInTriggerZone && !_isInPipTriggerZone) {
HapticFeedback.lightImpact(); // Exiting trigger zone
}
});
if (!wasInTriggerZone && _isInPipTriggerZone) {
HapticFeedback.mediumImpact();
} else if (wasInTriggerZone && !_isInPipTriggerZone) {
HapticFeedback.lightImpact();
}
}
void _handlePipDragEnd(DragEndDetails details) {
if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) {
if (!_isPipDragging ||
_videoStore.isInPipMode ||
_videoStore.paused ||
_videoStore.loading) {
return;
}
final velocity = details.velocity.pixelsPerSecond.dy;
final shouldTriggerPip =
_pipDragDistance >= _pipTriggerDistance ||
velocity > 600; // Simple velocity threshold
_pipDragDistance.value >= _pipTriggerDistance || velocity > 600;
if (shouldTriggerPip) {
// Simple haptic feedback on success
HapticFeedback.mediumImpact();
_videoStore.requestPictureInPicture();
_resetDragState();
} else {
// Animate back to original position
_animateSpringBack();
}
}
@ -179,16 +194,16 @@ class _VideoChatState extends State<VideoChat>
}
void _resetDragState() {
_pipDragDistance.value = 0;
_isInPipTriggerZone = false;
setState(() {
_isPipDragging = false;
_pipDragDistance = 0;
_isInPipTriggerZone = false;
});
}
void _animateSpringBack() {
_springBackAnimation = Tween<double>(begin: _pipDragDistance, end: 0)
.animate(
_springBackAnimation =
Tween<double>(begin: _pipDragDistance.value, end: 0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
);
@ -198,26 +213,45 @@ class _VideoChatState extends State<VideoChat>
});
}
void _scheduleSystemUiModeSync({required bool immersive}) {
if (_isImmersiveSystemUiEnabled == immersive) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _isImmersiveSystemUiEnabled == immersive) return;
_isImmersiveSystemUiEnabled = immersive;
if (immersive) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
} else {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
}
});
}
/// Wraps a video widget with PiP swipe-down gesture handling.
///
/// Provides visual feedback (translate + scale), haptic feedback,
/// and an instructional overlay during the drag gesture.
Widget _buildPipGestureWrapper({required Widget child, double? aspectRatio}) {
return AnimatedBuilder(
animation: _animationController,
builder: (context, _) {
final currentDragDistance = _isPipDragging
? _pipDragDistance
: _springBackAnimation.value;
final wrappedChild = aspectRatio != null
? AspectRatio(aspectRatio: aspectRatio, child: child)
: child;
// Listen to the ValueNotifier (drag) and AnimationController (spring-back)
// so only this subtree rebuilds during the gesture — not the full scaffold.
return ListenableBuilder(
listenable: _pipDragDistance,
child: wrappedChild,
builder: (context, animatedChild) {
final currentDragDistance = _pipDragDistance.value;
final scaleFactor =
1.0 - (currentDragDistance / _pipMaxDragDistance * 0.1);
Widget content = child;
if (aspectRatio != null) {
content = AspectRatio(aspectRatio: aspectRatio, child: child);
}
return Transform.translate(
offset: Offset(0, currentDragDistance),
child: Transform.scale(
@ -229,16 +263,18 @@ class _VideoChatState extends State<VideoChat>
onPanUpdate: _handlePipDragUpdate,
onPanEnd: _handlePipDragEnd,
onPanCancel: _handlePipDragCancel,
child: content,
child: animatedChild,
),
if (!_videoStore.isInPipMode)
Positioned.fill(
child: IgnorePointer(
ignoring: !(_isPipDragging && _pipDragDistance > 0),
ignoring:
!(_isPipDragging && currentDragDistance > 0),
child: AnimatedOpacity(
opacity: (_isPipDragging && _pipDragDistance > 0)
? 1.0
: 0.0,
opacity:
(_isPipDragging && currentDragDistance > 0)
? 1.0
: 0.0,
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
child: Container(
@ -275,7 +311,19 @@ class _VideoChatState extends State<VideoChat>
final player = GestureDetector(
onLongPress: _videoStore.handleToggleOverlay,
child: Video(key: _videoKey, videoStore: _videoStore),
child: _videoStore is NativeVideoStore
? NativeVideo(
// GlobalKey so the native platform view is *reparented* (not
// rebuilt) across the portrait↔landscape layout swap — without
// it, rotating destroys and recreates the player, forcing a
// reload. Mirrors the WebView Video below.
key: _videoKey,
nativeVideoStore: _videoStore,
)
: Video(
key: _videoKey,
videoStore: _videoStore as VideoStore,
),
);
final overlay = GestureDetector(
@ -302,7 +350,9 @@ class _VideoChatState extends State<VideoChat>
settingsStore: settingsStore,
);
if (_videoStore.paused || _videoStore.streamInfo == null) {
if (_videoStore.paused ||
_videoStore.streamInfo == null ||
_videoStore.loading) {
return videoOverlay;
}
@ -322,15 +372,49 @@ class _VideoChatState extends State<VideoChat>
),
);
final video = Observer(
builder: (context) {
return Stack(
children: [
player,
if (_videoStore.settingsStore.showOverlay) overlay,
],
);
},
final video = Stack(
children: [
player,
Positioned.fill(
child: Observer(
builder: (_) => AnimatedOpacity(
opacity: _videoStore.loading ? 1.0 : 0.0,
// Appear instantly to cover native player placeholders during
// recovery; fade out smoothly when playback resumes.
duration: _videoStore.loading
? Duration.zero
: const Duration(milliseconds: 300),
curve: Curves.easeOut,
// TickerMode stops the spinner's repeating animation while
// hidden — opacity 0 skips painting but would otherwise keep
// scheduling frames for the whole video session.
child: IgnorePointer(
child: TickerMode(
enabled: _videoStore.loading,
child: const ColoredBox(
color: Colors.black,
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: Colors.white38,
strokeWidth: 2,
),
),
),
),
),
),
),
),
),
Observer(
builder: (_) => _videoStore.settingsStore.showOverlay
? overlay
: const SizedBox.shrink(),
),
],
);
final chat = Observer(
@ -375,6 +459,11 @@ class _VideoChatState extends State<VideoChat>
final videoChat = Observer(
builder: (context) {
_scheduleSystemUiModeSync(
immersive:
context.isLandscape && !settingsStore.landscapeForceVerticalChat,
);
// Build a blurred AppBar when in chat-only mode (no video)
PreferredSizeWidget? chatOnlyBlurredAppBar;
if (!settingsStore.showVideo) {
@ -416,10 +505,6 @@ class _VideoChatState extends State<VideoChat>
builder: (context) {
if (context.isLandscape &&
!settingsStore.landscapeForceVerticalChat) {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.immersiveSticky,
);
final landscapeChat = AnimatedContainer(
curve: Curves.ease,
duration: _isDividerDragging
@ -445,11 +530,7 @@ class _VideoChatState extends State<VideoChat>
).dark,
child: DefaultTextStyle(
style: context.defaultTextStyle.copyWith(
color: context
.watch<FrostyThemes>()
.dark
.colorScheme
.onSurface,
color: context.frostyColors.overlayOnSurface,
),
child: landscapeChat,
),
@ -544,124 +625,114 @@ class _VideoChatState extends State<VideoChat>
)
: SafeArea(
bottom: false,
left: settingsStore.landscapeCutout !=
left:
settingsStore.landscapeCutout !=
LandscapeCutoutType.left &&
settingsStore.landscapeCutout !=
LandscapeCutoutType.both,
right: settingsStore.landscapeCutout !=
right:
settingsStore.landscapeCutout !=
LandscapeCutoutType.right &&
settingsStore.landscapeCutout !=
LandscapeCutoutType.both,
child: LayoutBuilder(
builder: (context, constraints) {
final availableWidth =
constraints.maxWidth;
final chatWidth = _chatStore.expandChat
? 0.5
: settingsStore.chatWidth;
builder: (context, constraints) {
final availableWidth = constraints.maxWidth;
final chatWidth = _chatStore.expandChat
? 0.5
: settingsStore.chatWidth;
// Create the landscape chat container with proper styling
final chatContainer = AnimatedContainer(
curve: Curves.ease,
duration: _isDividerDragging
? Duration.zero
: const Duration(
milliseconds: 200,
),
width: availableWidth * chatWidth,
color: settingsStore.fullScreen
? Colors.black.withValues(
alpha: settingsStore
.fullScreenChatOverlayOpacity,
)
: context.scaffoldColor,
child: chat,
);
// Create the landscape chat container with proper styling
final chatContainer = AnimatedContainer(
curve: Curves.ease,
duration: _isDividerDragging
? Duration.zero
: const Duration(milliseconds: 200),
width: availableWidth * chatWidth,
color: settingsStore.fullScreen
? Colors.black.withValues(
alpha: settingsStore
.fullScreenChatOverlayOpacity,
)
: context.scaffoldColor,
child: chat,
);
final draggableDivider = Observer(
builder: (_) => DraggableDivider(
currentWidth: chatWidth,
maxWidth: 0.6,
isResizableOnLeft: settingsStore
.landscapeChatLeftSide,
showHandle:
_videoStore.overlayVisible,
onDragStart: () {
setState(() {
_isDividerDragging = true;
});
},
onDrag: (newWidth) {
if (!_chatStore.expandChat) {
settingsStore.chatWidth =
newWidth;
}
},
onDragEnd: () {
setState(() {
_isDividerDragging = false;
});
},
),
);
final draggableDivider = Observer(
builder: (_) => DraggableDivider(
currentWidth: chatWidth,
maxWidth: 0.6,
isResizableOnLeft:
settingsStore.landscapeChatLeftSide,
showHandle: _videoStore.overlayVisible,
onDragStart: () {
setState(() {
_isDividerDragging = true;
});
},
onDrag: (newWidth) {
if (!_chatStore.expandChat) {
settingsStore.chatWidth = newWidth;
}
},
onDragEnd: () {
setState(() {
_isDividerDragging = false;
});
},
),
);
return Stack(
children: [
Row(
children:
settingsStore
.landscapeChatLeftSide
? [
chatContainer,
Expanded(
child:
_buildPipGestureWrapper(
child: video,
),
),
]
: [
Expanded(
child:
_buildPipGestureWrapper(
child: video,
),
),
chatContainer,
],
),
Positioned(
top: 0,
bottom: 0,
left:
settingsStore
.landscapeChatLeftSide
? (availableWidth *
chatWidth) -
12
: null,
right:
!settingsStore
.landscapeChatLeftSide
? (availableWidth *
chatWidth) -
12
: null,
child: draggableDivider,
),
],
);
},
),
)
return Stack(
children: [
Row(
children:
settingsStore
.landscapeChatLeftSide
? [
chatContainer,
Expanded(
child:
_buildPipGestureWrapper(
child: video,
),
),
]
: [
Expanded(
child:
_buildPipGestureWrapper(
child: video,
),
),
chatContainer,
],
),
Positioned(
top: 0,
bottom: 0,
left:
settingsStore
.landscapeChatLeftSide
? (availableWidth * chatWidth) -
12
: null,
right:
!settingsStore
.landscapeChatLeftSide
? (availableWidth * chatWidth) -
12
: null,
child: draggableDivider,
),
],
);
},
),
)
: SafeArea(child: chat),
);
}
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
return SafeArea(
// Keep chat-only scrolling under the blurred app bar, but
// ensure video does not bleed into the system status bar.
@ -674,7 +745,15 @@ class _VideoChatState extends State<VideoChat>
if (settingsStore.showVideo) ...[
AspectRatio(
aspectRatio: 16 / 9,
child: Container(), // Placeholder for video space
child: SizedBox(),
),
ChannelInfoHeader(
videoStore: _videoStore,
authStore: context.authStore,
userId: widget.userId,
displayName: widget.userName,
isInSharedChatMode:
_chatStore.isInSharedChatMode,
),
],
Expanded(child: chat),
@ -701,9 +780,16 @@ class _VideoChatState extends State<VideoChat>
// If on Android, use PiPSwitcher to enable PiP functionality.
if (Platform.isAndroid) {
// Both players need this — the WebView store used to be skipped by a
// NativeVideoPlayerInterface type check, so it never learned it was in
// PiP on Android.
void notifyPip(bool isInPip) => _videoStore.handleAndroidPipChanged(isInPip);
return PipWidget(
pipLayout: PipActionsLayout.mediaOnlyPause,
onPipAction: (_) => _videoStore.handlePausePlay(),
onPipEntered: () => notifyPip(true),
onPipExited: () => notifyPip(false),
pipChild: player,
child: videoChat,
);
@ -722,6 +808,7 @@ class _VideoChatState extends State<VideoChat>
_videoStore.dispose();
_animationController.dispose();
_pipDragDistance.dispose();
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,

View file

@ -1,14 +1,21 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart';
import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_bottom_bar.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/frosty_page_view.dart';
import 'package:frosty/widgets/frosty_scrollbar.dart';
/// ~2x default to keep richer message widgets (emotes, badges, replies) built
/// ahead of fast scroll-back without holding the entire history.
const _chatCacheExtent = ScrollCacheExtent.pixels(500.0);
class Chat extends StatelessWidget {
final ChatStore chatStore;
final EdgeInsetsGeometry? listPadding;
@ -17,13 +24,20 @@ class Chat extends StatelessWidget {
/// Passes this to ChatBottomBar for the ChatDetails menu.
final VoidCallback onAddChat;
/// When set, the widget operates in merged mode: messages come from all tabs
/// and the active tab determines the send target.
final ChatTabsStore? chatTabsStore;
const Chat({
super.key,
required this.chatStore,
this.listPadding,
required this.onAddChat,
this.chatTabsStore,
});
bool get _isMerged => chatTabsStore != null;
@override
Widget build(BuildContext context) {
return Observer(
@ -68,40 +82,63 @@ class Chat extends StatelessWidget {
? 0.0
: MediaQuery.of(context).padding.bottom;
return FrostyScrollbar(
controller: chatStore.scrollController,
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top,
bottom:
chatStore.bottomBarHeight + bottomPadding,
),
child: Observer(
builder: (context) {
return ListView.builder(
reverse: true,
padding: (listPadding ?? EdgeInsets.zero)
.add(
EdgeInsets.only(
bottom:
chatStore.bottomBarHeight +
bottomPadding,
),
),
addAutomaticKeepAlives: false,
controller: chatStore.scrollController,
itemCount: chatStore.renderMessages.length,
itemBuilder: (context, index) =>
ChatMessage(
ircMessage:
chatStore.renderMessages[chatStore
.renderMessages
.length -
1 -
index],
chatStore: chatStore,
),
final scrollController = _isMerged
? chatTabsStore!.mergedScrollController
: chatStore.scrollController;
// Freeze the message list while a finger rests on a
// message so a long-press target can't scroll out
// from under it (which would dispose its recognizer
// mid-press). The store resumes flowing as soon as
// the pointer moves like a scroll, so scrolling is
// never interrupted — only a stationary hold pauses.
void onDown(PointerDownEvent e) => _isMerged
? chatTabsStore!.onMessageListPointerDown(
e.pointer,
e.position,
)
: chatStore.onMessageListPointerDown(
e.pointer,
e.position,
);
},
void onMove(PointerMoveEvent e) => _isMerged
? chatTabsStore!.onMessageListPointerMove(
e.pointer,
e.position,
)
: chatStore.onMessageListPointerMove(
e.pointer,
e.position,
);
void onUp(int pointer) => _isMerged
? chatTabsStore!.onMessageListPointerUp(pointer)
: chatStore.onMessageListPointerUp(pointer);
return Listener(
onPointerDown: onDown,
onPointerMove: onMove,
onPointerUp: (e) => onUp(e.pointer),
onPointerCancel: (e) => onUp(e.pointer),
child: FrostyScrollbar(
controller: scrollController,
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top,
bottom:
chatStore.bottomBarHeight + bottomPadding,
),
child: Observer(
builder: (context) {
return _isMerged
? _buildMergedList(
scrollController,
bottomPadding,
)
: _buildNormalList(
scrollController,
bottomPadding,
);
},
),
),
);
},
@ -109,19 +146,6 @@ class Chat extends StatelessWidget {
),
),
),
// Prevents accidental chat scrolling when swiping down from the top edge
// to access system UI (Notification Center/Control Center) in landscape mode.
if (context.isLandscape)
Positioned(
top: 0,
left: 0,
right: 0,
height: 24,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onVerticalDragStart: (_) {},
),
),
Positioned(
left: 0,
right: 0,
@ -129,116 +153,227 @@ class Chat extends StatelessWidget {
child: ChatBottomBar(
chatStore: chatStore,
onAddChat: onAddChat,
channelDisplayName: _isMerged
? getReadableName(
chatTabsStore!.activeTab.displayName,
chatTabsStore!.activeTab.channelLogin,
)
: null,
),
),
Builder(
builder: (context) {
final isHorizontalLandscape =
context.isLandscape &&
!chatStore.settings.landscapeForceVerticalChat;
return AnimatedPadding(
duration: const Duration(milliseconds: 200),
padding: EdgeInsets.only(
left: 4,
top: 4,
right: 4,
bottom:
chatStore.bottomBarHeight +
(chatStore.assetsStore.showEmoteMenu ||
isHorizontalLandscape
? 0
: MediaQuery.of(context).padding.bottom),
),
child: Observer(
builder: (_) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: chatStore.autoScroll
? null
: ElevatedButton.icon(
onPressed: chatStore.resumeScroll,
icon: const Icon(
Icons.arrow_downward_rounded,
),
label: Text(
chatStore.messageBuffer.isNotEmpty
? '${chatStore.messageBuffer.length} new ${chatStore.messageBuffer.length == 1 ? 'message' : 'messages'}'
: 'Resume scroll',
style: const TextStyle(
fontFeatures: [
FontFeature.tabularFigures(),
],
),
),
),
),
),
);
},
),
_buildResumeScrollButton(),
],
),
),
AnimatedContainer(
curve: Curves.ease,
duration: const Duration(milliseconds: 200),
height: chatStore.assetsStore.showEmoteMenu
? context.screenHeight / (context.isPortrait ? 3 : 2)
: 0,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 100),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: chatStore.assetsStore.showEmoteMenu
? ClipRect(
child: Column(
children: [
const Divider(),
Expanded(
child: FrostyPageView(
headers: [
'Recent',
if (chatStore.settings.showTwitchEmotes)
'Twitch',
if (chatStore.settings.show7TVEmotes) '7TV',
if (chatStore.settings.showBTTVEmotes) 'BTTV',
if (chatStore.settings.showFFZEmotes) 'FFZ',
],
children: [
RecentEmotesPanel(chatStore: chatStore),
if (chatStore.settings.showTwitchEmotes)
EmoteMenuPanel(
chatStore: chatStore,
twitchEmotes: chatStore
.assetsStore
.userEmoteSectionToEmotes,
),
...[
if (chatStore.settings.show7TVEmotes)
chatStore.assetsStore.sevenTVEmotes,
if (chatStore.settings.showBTTVEmotes)
chatStore.assetsStore.bttvEmotes,
if (chatStore.settings.showFFZEmotes)
chatStore.assetsStore.ffzEmotes,
].map(
(emotes) => EmoteMenuPanel(
chatStore: chatStore,
emotes: emotes,
),
),
],
),
),
],
),
)
: null,
),
),
_buildEmoteMenu(context),
],
);
},
);
}
Widget _buildNormalList(
ScrollController scrollController,
double bottomPadding,
) {
return ListView.builder(
reverse: true,
padding: (listPadding ?? EdgeInsets.zero).add(
EdgeInsets.only(bottom: chatStore.bottomBarHeight + bottomPadding),
),
addAutomaticKeepAlives: false,
scrollCacheExtent: _chatCacheExtent,
controller: scrollController,
itemCount: chatStore.renderMessages.length,
itemBuilder: (context, index) {
final ircMessage = chatStore
.renderMessages[chatStore.renderMessages.length - 1 - index];
// Key by message identity so the list reconciles by message rather
// than by slot. This keeps each message's element (and its gesture
// recognizers) bound to the same message when new messages arrive
// mid-interaction, fixing long-press landing on the wrong message.
return ChatMessage(
key: ObjectKey(ircMessage),
ircMessage: ircMessage,
chatStore: chatStore,
);
},
);
}
Widget _buildMergedList(
ScrollController scrollController,
double bottomPadding,
) {
final mergedMessages = chatTabsStore!.mergedMessages;
final channelIdToUserTwitch = chatTabsStore!.mergedChannelIdToUserTwitch;
final currentChannelId = chatTabsStore!.activeTab.channelId;
return ListView.builder(
reverse: true,
padding: (listPadding ?? EdgeInsets.zero).add(
EdgeInsets.only(bottom: chatStore.bottomBarHeight + bottomPadding),
),
addAutomaticKeepAlives: false,
scrollCacheExtent: _chatCacheExtent,
controller: scrollController,
itemCount: mergedMessages.length,
itemBuilder: (context, index) {
final merged = mergedMessages[mergedMessages.length - 1 - index];
return ChatMessage(
key: ObjectKey(merged.ircMessage),
ircMessage: merged.ircMessage,
chatStore: merged.chatStore,
inputChatStore: chatStore,
onActivateSourceTab: () {
final tabs = chatTabsStore!.tabs;
final idx = tabs.indexWhere(
(t) => t.channelId == merged.chatStore.channelId,
);
if (idx != -1 && idx != chatTabsStore!.activeTabIndex) {
chatTabsStore!.setActiveTab(idx, silent: true);
}
},
overrideChannelIdToUserTwitch: channelIdToUserTwitch,
overrideCurrentChannelId: currentChannelId,
);
},
);
}
Widget _buildResumeScrollButton() {
return Builder(
builder: (context) {
final isHorizontalLandscape =
context.isLandscape &&
!chatStore.settings.landscapeForceVerticalChat;
return AnimatedPadding(
duration: const Duration(milliseconds: 200),
padding: EdgeInsets.only(
left: 4,
top: 4,
right: 4,
bottom:
chatStore.bottomBarHeight +
(chatStore.assetsStore.showEmoteMenu || isHorizontalLandscape
? 0
: MediaQuery.of(context).padding.bottom),
),
child: Observer(
builder: (_) {
final isAutoScrolling = _isMerged
? chatTabsStore!.mergedAutoScroll
: chatStore.autoScroll;
final bufferCount = _isMerged
? chatTabsStore!.mergedBufferCount
: chatStore.messageBuffer.length;
final onResume = _isMerged
? chatTabsStore!.resumeMergedScroll
: chatStore.resumeScroll;
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: isAutoScrolling
? null
: ElevatedButton.icon(
onPressed: onResume,
icon: const Icon(Icons.arrow_downward_rounded),
label: Text(
bufferCount > 0
? '$bufferCount new ${bufferCount == 1 ? 'message' : 'messages'}'
: 'Resume scroll',
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
),
),
);
},
),
);
},
);
}
Widget _buildEmoteMenu(BuildContext context) {
final menuHeight = context.screenHeight / (context.isPortrait ? 3 : 2);
return AnimatedContainer(
curve: Curves.ease,
duration: const Duration(milliseconds: 200),
height: chatStore.assetsStore.showEmoteMenu ? menuHeight : 0,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 100),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: chatStore.assetsStore.showEmoteMenu
? ClipRect(
// Lay the content out at its full target height regardless of
// the animated container's current height, then clip — the
// Column would otherwise overflow (RenderFlex error) as the
// height tweens toward zero and can no longer fit the Divider.
child: OverflowBox(
minHeight: 0,
maxHeight: menuHeight,
alignment: Alignment.topCenter,
child: Column(
children: [
const Divider(),
Expanded(
child: FrostyPageView(
headers: [
'Recent',
if (chatStore.settings.showTwitchEmotes) 'Twitch',
if (chatStore.settings.show7TVEmotes) '7TV',
if (chatStore.settings.showBTTVEmotes) 'BTTV',
if (chatStore.settings.showFFZEmotes) 'FFZ',
],
tabActions: {
if (chatStore.assetsStore.recentEmotes.isNotEmpty)
0: IconButton(
onPressed: () {
chatStore.assetsStore.recentEmotes.clear();
chatStore.updateNotification(
'Recent emotes cleared',
);
},
icon: const Icon(Icons.delete_outline_rounded),
tooltip: 'Clear recent emotes',
iconSize: 20,
),
},
children: [
RecentEmotesPanel(chatStore: chatStore),
if (chatStore.settings.showTwitchEmotes)
EmoteMenuPanel(
chatStore: chatStore,
twitchEmotes: chatStore
.assetsStore
.userEmoteSectionToEmotes,
),
...[
if (chatStore.settings.show7TVEmotes)
chatStore.assetsStore.sevenTVEmotes,
if (chatStore.settings.showBTTVEmotes)
chatStore.assetsStore.bttvEmotes,
if (chatStore.settings.showFFZEmotes)
chatStore.assetsStore.ffzEmotes,
].map(
(emotes) => EmoteMenuPanel(
chatStore: chatStore,
emotes: emotes,
),
),
],
),
),
],
),
),
)
: null,
),
);
}
}

View file

@ -416,6 +416,7 @@ class _ChatDetailsState extends State<ChatDetails> {
onTap: () => Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: Settings.routeName),
builder: (context) =>
Settings(settingsStore: widget.chatStore.settings),
),

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/models/irc.dart';
import 'package:frosty/utils/context_extensions.dart';
class ChatModes extends StatelessWidget {
final ROOMSTATE roomState;
@ -27,7 +28,7 @@ class ChatModes extends StatelessWidget {
label: 'Emote only',
activeLabel: 'Emote only',
isActive: true,
activeColor: const Color(0xFFFFB74D), // Slightly lighter orange
activeColor: context.frostyColors.emoteOnly,
),
),
);
@ -44,7 +45,7 @@ class ChatModes extends StatelessWidget {
label: 'Follower only',
activeLabel: 'Follower only',
isActive: true,
activeColor: const Color(0xFFF44336), // Bright red
activeColor: context.frostyColors.followersOnly,
duration: _getFollowersDuration(),
),
),
@ -62,7 +63,7 @@ class ChatModes extends StatelessWidget {
label: 'Slow mode',
activeLabel: 'Slow mode',
isActive: true,
activeColor: const Color(0xFF2196F3), // Bright blue
activeColor: context.frostyColors.slowMode,
duration: _getSlowModeDuration(),
),
),
@ -80,7 +81,7 @@ class ChatModes extends StatelessWidget {
label: 'Sub only',
activeLabel: 'Sub only',
isActive: true,
activeColor: const Color(0xFF4CAF50), // Bright green
activeColor: context.frostyColors.subOnly,
),
),
);
@ -97,7 +98,7 @@ class ChatModes extends StatelessWidget {
label: 'Unique mode',
activeLabel: 'Unique mode',
isActive: true,
activeColor: const Color(0xFFAB47BC), // Slightly lighter purple
activeColor: context.frostyColors.uniqueMode,
),
),
);
@ -170,7 +171,7 @@ class ChatModes extends StatelessWidget {
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
).colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
],

View file

@ -8,7 +8,6 @@ import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/frosty_cached_network_image.dart';
import 'package:frosty/widgets/frosty_dialog.dart';
import 'package:provider/provider.dart';
class RecentEmotesPanel extends StatelessWidget {
@ -16,122 +15,74 @@ class RecentEmotesPanel extends StatelessWidget {
const RecentEmotesPanel({super.key, required this.chatStore});
Future<void> _showClearDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => FrostyDialog(
title: 'Clear recent emotes',
message: 'Are you sure you want to clear your recent emotes?',
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
chatStore.assetsStore.recentEmotes.clear();
chatStore.updateNotification('Recent emotes cleared');
Navigator.pop(context);
},
child: const Text('Clear'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Observer(
builder: (context) {
if (chatStore.assetsStore.recentEmotes.isEmpty) {
return const Center(
child: AlertMessage(message: 'No recent emotes'),
);
}
return MediaQuery.removePadding(
context: context,
removeTop: true,
child: CustomScrollView(
slivers: [
if (chatStore.assetsStore.recentEmotes.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: AlertMessage(message: 'No recent emotes'),
)
else
SliverGrid.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: context.isPortrait
? 8
: context.read<SettingsStore>().showVideo
? 6
: 16,
),
itemBuilder: (context, index) {
final emote = chatStore.assetsStore.recentEmotes[index];
final validEmotes = [
...chatStore.assetsStore.emoteToObject.values,
...chatStore.assetsStore.userEmoteToObject.values,
];
final matchingEmotes = validEmotes.where(
(existingEmote) =>
existingEmote.name == emote.name &&
existingEmote.type == emote.type,
);
child: GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: context.isPortrait
? 8
: context.read<SettingsStore>().showVideo
? 6
: 16,
),
itemBuilder: (context, index) {
final emote = chatStore.assetsStore.recentEmotes[index];
final validEmotes = [
...chatStore.assetsStore.emoteToObject.values,
...chatStore.assetsStore.userEmoteToObject.values,
];
final matchingEmotes = validEmotes.where(
(existingEmote) =>
existingEmote.name == emote.name &&
existingEmote.type == emote.type,
);
return InkWell(
onTap: matchingEmotes.isNotEmpty
? () => chatStore.addEmote(emote)
: null,
onLongPress: () {
HapticFeedback.lightImpact();
return InkWell(
onTap: matchingEmotes.isNotEmpty
? () => chatStore.addEmote(emote)
: null,
onLongPress: () {
HapticFeedback.lightImpact();
IRCMessage.showEmoteDetailsBottomSheet(
context,
emote: emote,
launchExternal: chatStore.settings.launchUrlExternal,
);
},
child: Padding(
padding: const EdgeInsets.all(8),
child: Center(
child: FrostyCachedNetworkImage(
imageUrl: matchingEmotes.isNotEmpty
? matchingEmotes.first.url
: emote.url,
color: matchingEmotes.isNotEmpty
? null
: const Color.fromRGBO(255, 255, 255, 0.5),
colorBlendMode: matchingEmotes.isNotEmpty
? null
: BlendMode.modulate,
height:
emote.height?.toDouble() ?? defaultEmoteSize,
width: emote.width?.toDouble(),
),
),
),
);
},
itemCount: chatStore.assetsStore.recentEmotes.length,
),
SliverFillRemaining(
hasScrollBody: false,
child: Align(
alignment: Alignment.bottomCenter,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: SizedBox(
width: double.infinity,
child: TextButton.icon(
onPressed: () => _showClearDialog(context),
icon: const Icon(Icons.clear_all_rounded),
label: const Text('Clear recent emotes'),
),
),
IRCMessage.showEmoteDetailsBottomSheet(
context,
emote: emote,
launchExternal: chatStore.settings.launchUrlExternal,
);
},
child: Padding(
padding: const EdgeInsets.all(8),
child: Center(
child: FrostyCachedNetworkImage(
imageUrl: matchingEmotes.isNotEmpty
? matchingEmotes.first.url
: emote.url,
color: matchingEmotes.isNotEmpty
? null
: const Color.fromRGBO(255, 255, 255, 0.5),
colorBlendMode: matchingEmotes.isNotEmpty
? null
: BlendMode.modulate,
height:
emote.height?.toDouble() ?? defaultEmoteSize,
width: emote.width?.toDouble(),
),
),
),
),
],
);
},
itemCount: chatStore.assetsStore.recentEmotes.length,
),
);
},

View file

@ -0,0 +1,31 @@
/// Tracks which users are currently banned or timed out, learned from IRC
/// CLEARCHAT events, so the chat can offer an "unban / remove timeout" action
/// only when it's actually applicable.
///
/// Keyed by Twitch user id. A value of null means a permanent ban; a non-null
/// [DateTime] is when a timeout expires. Pure logic (clock passed in) so it can
/// be unit-tested without the chat store.
class BannedUserTracker {
final _expiries = <String, DateTime?>{};
/// Record a permanent ban (CLEARCHAT without a ban-duration).
void recordBan(String userId) => _expiries[userId] = null;
/// Record a timeout (CLEARCHAT with a ban-duration), expiring at [now] + [duration].
void recordTimeout(String userId, Duration duration, DateTime now) =>
_expiries[userId] = now.add(duration);
/// Forget a user (e.g. after a successful unban).
void clear(String userId) => _expiries.remove(userId);
/// Whether [userId] is currently banned or within an unexpired timeout.
/// Expired timeouts are pruned on read.
bool isBannedOrTimedOut(String userId, DateTime now) {
if (!_expiries.containsKey(userId)) return false;
final expiry = _expiries[userId];
if (expiry == null) return true; // permanent ban
if (now.isBefore(expiry)) return true; // timeout still active
_expiries.remove(userId); // expired — clean up
return false;
}
}

View file

@ -130,7 +130,7 @@ abstract class ChatAssetsStoreBase with Store {
@action
Future<void> init() async {
// Retrieve the instance that will allow us to retrieve local search history.
// Load SharedPreferences for recent emotes.
final prefs = await SharedPreferences.getInstance();
_recentEmotes =
@ -290,6 +290,18 @@ abstract class ChatAssetsStoreBase with Store {
await Future.wait(futures);
}
/// Whether any shared chat participant assets have been merged in.
bool get hasLoadedSharedChatAssets => _loadedSharedChannelIds.isNotEmpty;
/// Clears tracking of merged shared chat participant assets so that leaving
/// and later re-entering a shared chat session re-fetches cleanly. The caller
/// must re-fetch this channel's own assets afterwards (which replaces the
/// merged emote/badge maps). Must run within a MobX action.
void resetSharedChatAssets() {
_loadedSharedChannelIds.clear();
sharedSevenTvSetIds.clear();
}
/// Load and cache shared chat participants for a broadcaster.
/// Returns the list of new participant broadcaster IDs (deduped).
@action
@ -443,5 +455,19 @@ abstract class ChatAssetsStoreBase with Store {
}
}
/// Adds a channel emote (e.g., from a live 7TV event).
/// Mutates the observable `_channelEmoteToObject` so `emoteToObject` recomputes.
@action
void addChannelEmote(Emote emote) {
_channelEmoteToObject[emote.name] = emote;
}
/// Removes a channel emote by name (e.g., from a live 7TV event).
/// Mutates the observable `_channelEmoteToObject` so `emoteToObject` recomputes.
@action
void removeChannelEmote(String emoteName) {
_channelEmoteToObject.remove(emoteName);
}
void dispose() => _disposeReaction();
}

View file

@ -320,6 +320,35 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store {
);
}
late final _$ChatAssetsStoreBaseActionController = ActionController(
name: 'ChatAssetsStoreBase',
context: context,
);
@override
void addChannelEmote(Emote emote) {
final _$actionInfo = _$ChatAssetsStoreBaseActionController.startAction(
name: 'ChatAssetsStoreBase.addChannelEmote',
);
try {
return super.addChannelEmote(emote);
} finally {
_$ChatAssetsStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void removeChannelEmote(String emoteName) {
final _$actionInfo = _$ChatAssetsStoreBaseActionController.startAction(
name: 'ChatAssetsStoreBase.removeChannelEmote',
);
try {
return super.removeChannelEmote(emoteName);
} finally {
_$ChatAssetsStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''

View file

@ -0,0 +1,54 @@
import 'package:flutter/gestures.dart';
/// Decides whether the chat message list should hold buffered messages back
/// because the user is *holding* a message (a long-press candidate).
///
/// Flushing buffered messages grows the reverse list and pushes the pressed
/// message up and out of the build range, which disposes its gesture recognizer
/// mid-press and cancels the long-press. Freezing the list while a finger rests
/// on a message keeps it under the finger so the press lands.
///
/// Crucially, the freeze applies *only to a stationary finger*. The moment a
/// pointer travels past [kTouchSlop] it's a scroll, not a hold, so the chat
/// resumes flowing — otherwise freezing then batch-releasing mid-scroll makes
/// scrolling feel like it gets cancelled. This is the same threshold the
/// long-press recognizer uses to tell a hold from a drag.
///
/// Tracks pointers individually so multi-touch resolves correctly: the chat
/// only resumes once every stationary pointer has lifted or started moving.
class ChatInteractionPause {
/// Down position of each active pointer, by pointer id.
final _origins = <int, Offset>{};
/// Pointers that have moved past the slop this gesture — i.e. are scrolling.
/// Once a pointer is here it stays until it lifts, so a drag never flips back
/// to a hold mid-gesture.
final _scrolling = <int>{};
/// Whether any pointer is down and still stationary (a long-press candidate).
bool get isPaused => _origins.length > _scrolling.length;
/// Register a pointer touching down at [position].
void pointerDown(int pointer, Offset position) {
_origins[pointer] = position;
}
/// Update a pointer's position; flags it as scrolling once it passes the slop.
void pointerMove(int pointer, Offset position) {
final origin = _origins[pointer];
if (origin == null) return;
if ((position - origin).distance > kTouchSlop) _scrolling.add(pointer);
}
/// Register a pointer lifting off (or being cancelled).
void pointerUp(int pointer) {
_origins.remove(pointer);
_scrolling.remove(pointer);
}
/// Force-clear all active pointers (e.g. on dispose or tab switch).
void reset() {
_origins.clear();
_scrolling.clear();
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,15 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:frosty/apis/bttv_api.dart';
import 'package:frosty/apis/ffz_api.dart';
import 'package:frosty/apis/seventv_api.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/models/irc.dart';
import 'package:frosty/models/user.dart';
import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_interaction_pause.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
@ -50,6 +56,10 @@ class ChatTabInfo {
/// The ChatStore for this tab. Null until the tab is activated (lazy loading).
ChatStore? chatStore;
/// The id of the most recent message visible the last time the user viewed
/// this tab. Used to compute unread state — null means "no baseline yet".
String? lastSeenMessageId;
/// Whether this is the primary tab (first tab, cannot be removed).
final bool isPrimary;
@ -65,6 +75,14 @@ class ChatTabInfo {
});
}
/// A message paired with its source ChatStore for merged view rendering.
class MergedMessage {
final IRCMessage ircMessage;
final ChatStore chatStore;
const MergedMessage({required this.ircMessage, required this.chatStore});
}
/// Store that manages multiple chat tabs.
class ChatTabsStore = ChatTabsStoreBase with _$ChatTabsStore;
@ -72,6 +90,9 @@ abstract class ChatTabsStoreBase with Store {
/// Maximum number of tabs allowed.
static const maxTabs = 10;
/// Max messages to render in merged view when autoscrolling.
static const _mergedRenderLimit = 100;
/// API services needed for creating ChatStore instances.
final TwitchApi twitchApi;
final BTTVApi bttvApi;
@ -108,6 +129,175 @@ abstract class ChatTabsStoreBase with Store {
@computed
bool get showTabBar => _tabs.length > 1;
/// Whether merged chat mode is active (session-only, not persisted).
@observable
var mergedMode = false;
/// Channel profiles for badge rendering in merged view.
final _tabChannelProfiles = ObservableMap<String, UserTwitch>();
/// Scroll controller for the merged chat view.
final mergedScrollController = ScrollController();
/// Whether the merged view should auto-scroll.
@readonly
var _mergedAutoScroll = true;
/// Timer-driven rendered messages for merged view.
/// Updated every 200ms to match normal chat's batched flush cadence,
/// instead of reacting to each per-tab ObservableList mutation.
@readonly
var _mergedRenderedMessages = <MergedMessage>[];
/// Timer that drives merged message rendering at a fixed cadence.
Timer? _mergedRenderTimer;
/// Freezes the rendered merged list while the user is touching it, so a
/// long-press target can't scroll out from under the finger. Gates the
/// periodic [_refreshMergedMessages]; see [ChatInteractionPause].
final _interactionPause = ChatInteractionPause();
/// Hooks for the merged message list to report touch start/move/end (see [Chat]).
void onMessageListPointerDown(int pointer, Offset position) =>
_interactionPause.pointerDown(pointer, position);
void onMessageListPointerMove(int pointer, Offset position) =>
_interactionPause.pointerMove(pointer, position);
void onMessageListPointerUp(int pointer) =>
_interactionPause.pointerUp(pointer);
/// Frozen snapshot of merged messages when user scrolls up.
/// Prevents new messages from causing scroll jumps while reading.
List<MergedMessage>? _mergedSnapshot;
/// Total message count across all tabs when the snapshot was taken.
int _mergedSnapshotTotalCount = 0;
/// Combined channel-to-user map for merged view badge rendering.
@computed
Map<String, UserTwitch> get mergedChannelIdToUserTwitch {
final merged = <String, UserTwitch>{};
for (final tab in _tabs) {
if (tab.chatStore != null) {
merged.addAll(tab.chatStore!.assetsStore.channelIdToUserTwitch);
}
}
merged.addAll(_tabChannelProfiles);
return merged;
}
static int _compareByTimestamp(MergedMessage a, MergedMessage b) {
final aTs = int.tryParse(a.ircMessage.tags['tmi-sent-ts'] ?? '') ?? 0;
final bTs = int.tryParse(b.ircMessage.tags['tmi-sent-ts'] ?? '') ?? 0;
return aTs.compareTo(bTs);
}
/// Adds messages from [store] to [out], deduplicating by message ID
/// (handles Twitch shared chat where the same message arrives on multiple
/// IRC connections). Includes messageBuffer only when the tab's own
/// autoScroll is off (paused tabs whose timer won't flush).
void _collectMessages(
ChatStore store,
List<MergedMessage> out,
Set<String> seenIds, {
int? tailCount,
}) {
final msgs = store.messages;
final start = tailCount != null && msgs.length > tailCount
? msgs.length - tailCount
: 0;
for (var i = start; i < msgs.length; i++) {
final id = msgs[i].tags['id'];
if (id != null && !seenIds.add(id)) continue;
out.add(MergedMessage(ircMessage: msgs[i], chatStore: store));
}
// Only read messageBuffer for paused tabs — their timer won't flush
// so these messages would otherwise be invisible in merged view.
if (!store.autoScroll) {
for (final msg in store.messageBuffer) {
final id = msg.tags['id'];
if (id != null && !seenIds.add(id)) continue;
out.add(MergedMessage(ircMessage: msg, chatStore: store));
}
}
}
/// Collects, sorts, and caps all messages from all tabs.
List<MergedMessage> _computeAllMergedMessages() {
final allMessages = <MergedMessage>[];
final seenIds = <String>{};
for (final tab in _tabs) {
if (tab.chatStore == null) continue;
_collectMessages(tab.chatStore!, allMessages, seenIds);
}
allMessages.sort(_compareByTimestamp);
return allMessages;
}
/// Fast path: only collects the tail of each tab's messages for autoscroll.
/// Instead of sorting all 50k messages, sorts at most k*100 items.
List<MergedMessage> _computeRecentMergedMessages() {
final recent = <MergedMessage>[];
final seenIds = <String>{};
for (final tab in _tabs) {
if (tab.chatStore == null) continue;
_collectMessages(
tab.chatStore!,
recent,
seenIds,
tailCount: _mergedRenderLimit,
);
}
recent.sort(_compareByTimestamp);
if (recent.length > _mergedRenderLimit) {
return recent.sublist(recent.length - _mergedRenderLimit);
}
return recent;
}
/// Interleaved messages from all activated tabs, sorted by timestamp.
/// During autoscroll, returns timer-driven [_mergedRenderedMessages] which
/// updates every 200ms — matching normal chat's batched flush cadence.
/// When scrolled up, returns a frozen snapshot.
@computed
List<MergedMessage> get mergedMessages {
if (!_mergedAutoScroll && _mergedSnapshot != null) {
return _mergedSnapshot!;
}
return _mergedRenderedMessages;
}
/// Refreshes the rendered merged messages. Called by the render timer.
@action
void _refreshMergedMessages() {
// Hold the rendered list still while the user is touching it so a
// long-press target stays put for the duration of the press.
if (!_mergedAutoScroll || _interactionPause.isPaused) return;
_mergedRenderedMessages = _computeRecentMergedMessages();
}
/// Count of new messages since the user scrolled up (for "N new messages").
/// Total messages (messages + messageBuffer) across all tabs.
int _totalMessageCount() {
var total = 0;
for (final tab in _tabs) {
if (tab.chatStore != null) {
total +=
tab.chatStore!.messages.length +
tab.chatStore!.messageBuffer.length;
}
}
return total;
}
@computed
int get mergedBufferCount {
if (!_mergedAutoScroll && _mergedSnapshot != null) {
final diff = _totalMessageCount() - _mergedSnapshotTotalCount;
return diff > 0 ? diff : 0;
}
return 0;
}
/// Public getter for the tabs list.
List<ChatTabInfo> get tabs => _tabs;
@ -124,7 +314,9 @@ abstract class ChatTabsStoreBase with Store {
required String primaryDisplayName,
}) {
// Enable wakelock once for all chat tabs
WakelockPlus.enable();
if (settingsStore.keepScreenAwake) {
WakelockPlus.enable();
}
// Initialize with the primary channel tab
_addPrimaryTab(
@ -137,6 +329,26 @@ abstract class ChatTabsStoreBase with Store {
if (settingsStore.persistChatTabs) {
_restoreSecondaryTabs(primaryChannelId: primaryChannelId);
}
// Set up merged scroll listener once (reused across toggle cycles)
mergedScrollController.addListener(() {
if (!mergedScrollController.hasClients) return;
runInAction(() {
if (mergedScrollController.position.pixels <= 0) {
if (!_mergedAutoScroll) {
_mergedSnapshot = null;
_mergedAutoScroll = true;
}
} else if (mergedScrollController.position.pixels > 0) {
if (_mergedAutoScroll) {
// Capture snapshot so the list stays frozen while scrolling
_mergedSnapshot = _computeAllMergedMessages();
_mergedSnapshotTotalCount = _totalMessageCount();
_mergedAutoScroll = false;
}
}
});
});
}
/// Creates a ChatStore for a given channel.
@ -166,6 +378,62 @@ abstract class ChatTabsStoreBase with Store {
);
}
/// Toggles merged chat mode. Only already-activated tabs are included;
/// tapping a lazy tab in merged mode activates it and adds it to the merge.
@action
void toggleMergedMode() {
mergedMode = !mergedMode;
if (mergedMode) {
// Reset scroll state
_mergedAutoScroll = true;
_mergedSnapshot = null;
// Fetch channel profiles for activated tabs only
for (final tab in _tabs) {
if (tab.chatStore != null) {
_fetchTabChannelProfile(tab);
}
}
// Start render timer — compute immediately then every 200ms
_refreshMergedMessages();
_mergedRenderTimer?.cancel();
_mergedRenderTimer = Timer.periodic(
const Duration(milliseconds: 200),
(_) => _refreshMergedMessages(),
);
} else {
_mergedRenderTimer?.cancel();
_mergedRenderTimer = null;
_mergedRenderedMessages = [];
}
}
/// Re-enables auto-scroll and jumps to the latest message in merged view.
@action
void resumeMergedScroll() {
_mergedSnapshot = null;
_mergedAutoScroll = true;
// Refresh immediately so the list shows latest messages before the
// next timer tick.
_refreshMergedMessages();
mergedScrollController.jumpTo(0);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mergedScrollController.hasClients) {
mergedScrollController.jumpTo(0);
}
});
}
/// Fetches the channel profile for badge rendering in merged view.
Future<void> _fetchTabChannelProfile(ChatTabInfo tab) async {
if (_tabChannelProfiles.containsKey(tab.channelId)) return;
try {
final user = await twitchApi.getUser(id: tab.channelId);
_tabChannelProfiles[tab.channelId] = user;
} catch (e) {
debugPrint('Failed to fetch profile for ${tab.channelLogin}: $e');
}
}
/// Activates a tab by creating its ChatStore if not already activated.
@action
void activateTab(int index) {
@ -274,10 +542,14 @@ abstract class ChatTabsStoreBase with Store {
),
);
// Switch to the new tab (this will activate it)
// Switch to the new tab, snapshotting the outgoing tab's lastSeenMessageId.
final newIndex = _tabs.length - 1;
activateTab(newIndex);
activeTabIndex = newIndex;
setActiveTab(newIndex);
// If in merged mode, fetch the new tab's channel profile
if (mergedMode) {
_fetchTabChannelProfile(_tabs[newIndex]);
}
// Sync to settings for persistence
_syncSecondaryTabsToSettings();
@ -285,6 +557,39 @@ abstract class ChatTabsStoreBase with Store {
return true;
}
/// Deactivates a tab by disposing its ChatStore without removing the tab.
/// The tab stays in the tab bar but appears dimmed. Tapping reactivates it.
@action
void deactivateTab(int index) {
if (index < 0 || index >= _tabs.length) return;
final tab = _tabs[index];
if (tab.isPrimary) return;
if (tab.chatStore == null) return;
// If this is the active tab, switch to primary
if (index == activeTabIndex) {
activeTabIndex = 0;
}
// Clean up merged mode state
if (mergedMode) {
_tabChannelProfiles.remove(tab.channelId);
_mergedSnapshot = null;
}
// Dispose the ChatStore (closes IRC connection)
tab.chatStore!.dispose();
tab.chatStore = null;
// Exit merged mode if fewer than 2 tabs remain activated
if (mergedMode && _tabs.where((t) => t.chatStore != null).length < 2) {
mergedMode = false;
_mergedRenderTimer?.cancel();
_mergedRenderTimer = null;
_mergedRenderedMessages = [];
}
}
/// Removes the tab at the given index.
/// Returns true if removed, false if primary tab or invalid index.
@action
@ -299,12 +604,25 @@ abstract class ChatTabsStoreBase with Store {
return false;
}
// Clean up merged mode state
_tabChannelProfiles.remove(_tabs[index].channelId);
// Clear stale snapshot that may reference this tab's disposed ChatStore
_mergedSnapshot = null;
// Dispose the ChatStore before removing (if activated)
_tabs[index].chatStore?.dispose();
// Remove the tab
_tabs.removeAt(index);
// Disable merged mode if only 1 tab remains
if (_tabs.length <= 1 && mergedMode) {
mergedMode = false;
_mergedRenderTimer?.cancel();
_mergedRenderTimer = null;
_mergedRenderedMessages = [];
}
// Adjust active index if necessary
if (activeTabIndex >= _tabs.length) {
activeTabIndex = _tabs.length - 1;
@ -328,10 +646,8 @@ abstract class ChatTabsStoreBase with Store {
// Primary tab (index 0) cannot be moved, and nothing can move before it
if (oldIndex == 0 || newIndex == 0) return;
// ReorderableListView passes newIndex as if item was already removed
if (oldIndex < newIndex) {
newIndex -= 1;
}
// onReorderItem already adjusts newIndex for the removed item, so no
// manual off-by-one correction is needed here.
if (oldIndex == newIndex) return;
final tab = _tabs.removeAt(oldIndex);
@ -349,12 +665,54 @@ abstract class ChatTabsStoreBase with Store {
_syncSecondaryTabsToSettings();
}
/// Returns the last message id in [messages] that has a non-null tag id,
/// or null if no such message exists. Skips system messages without ids.
String? _latestMessageId(List<IRCMessage> messages) {
for (var i = messages.length - 1; i >= 0; i--) {
final id = messages[i].tags['id'];
if (id != null) return id;
}
return null;
}
/// True if [index] has unread messages since the user last viewed it.
/// Always false for the active tab and in merged mode.
bool hasUnreadMessages(int index) {
if (mergedMode) return false;
if (index == activeTabIndex) return false;
if (index < 0 || index >= _tabs.length) return false;
final tab = _tabs[index];
final store = tab.chatStore;
if (store == null) return false;
final latest = _latestMessageId(store.messages);
if (latest == null) return false;
return latest != tab.lastSeenMessageId;
}
/// Sets the active tab to the given index.
///
/// When [silent] is true, the current tab's draft text and reply state are
/// preserved. Used for programmatic switches (e.g., reply/paste routing in
/// merged mode) where wiping the draft would lose user work.
@action
void setActiveTab(int index) {
void setActiveTab(int index, {bool silent = false}) {
if (index >= 0 && index < _tabs.length) {
// Clear text input and emote menu when switching tabs
if (index != activeTabIndex) {
// Snapshot the outgoing tab's most recent message id so the unread
// dot for that tab clears, and only future messages count as unread.
if (index != activeTabIndex &&
activeTabIndex >= 0 &&
activeTabIndex < _tabs.length) {
final outgoing = _tabs[activeTabIndex];
final outStore = outgoing.chatStore;
if (outStore != null) {
outgoing.lastSeenMessageId = _latestMessageId(outStore.messages);
}
}
// Clear text input and emote menu when switching tabs.
// Skip in merged mode — tabs act as send-target selectors, not
// view switches, so draft/reply state should be preserved.
if (index != activeTabIndex && !silent && !mergedMode) {
final currentStore = _tabs[activeTabIndex].chatStore;
if (currentStore != null) {
// Close emote menu if open
@ -371,16 +729,24 @@ abstract class ChatTabsStoreBase with Store {
// Activate the target tab if not already activated
activateTab(index);
if (mergedMode) {
_fetchTabChannelProfile(_tabs[index]);
}
activeTabIndex = index;
}
}
/// Disposes all ChatStores and cleans up resources.
void dispose() {
_mergedRenderTimer?.cancel();
_mergedRenderTimer = null;
_interactionPause.reset();
for (final tab in _tabs) {
tab.chatStore?.dispose();
}
_tabs.clear();
mergedScrollController.dispose();
// Disable wakelock once after all tabs disposed
WakelockPlus.disable();

View file

@ -56,6 +56,31 @@ mixin _$ChatTabsStore on ChatTabsStoreBase, Store {
() => super.showTabBar,
name: 'ChatTabsStoreBase.showTabBar',
)).value;
Computed<Map<String, UserTwitch>>? _$mergedChannelIdToUserTwitchComputed;
@override
Map<String, UserTwitch> get mergedChannelIdToUserTwitch =>
(_$mergedChannelIdToUserTwitchComputed ??=
Computed<Map<String, UserTwitch>>(
() => super.mergedChannelIdToUserTwitch,
name: 'ChatTabsStoreBase.mergedChannelIdToUserTwitch',
))
.value;
Computed<List<MergedMessage>>? _$mergedMessagesComputed;
@override
List<MergedMessage> get mergedMessages =>
(_$mergedMessagesComputed ??= Computed<List<MergedMessage>>(
() => super.mergedMessages,
name: 'ChatTabsStoreBase.mergedMessages',
)).value;
Computed<int>? _$mergedBufferCountComputed;
@override
int get mergedBufferCount => (_$mergedBufferCountComputed ??= Computed<int>(
() => super.mergedBufferCount,
name: 'ChatTabsStoreBase.mergedBufferCount',
)).value;
late final _$_tabsAtom = Atom(
name: 'ChatTabsStoreBase._tabs',
@ -95,11 +120,109 @@ mixin _$ChatTabsStore on ChatTabsStoreBase, Store {
});
}
late final _$mergedModeAtom = Atom(
name: 'ChatTabsStoreBase.mergedMode',
context: context,
);
@override
bool get mergedMode {
_$mergedModeAtom.reportRead();
return super.mergedMode;
}
@override
set mergedMode(bool value) {
_$mergedModeAtom.reportWrite(value, super.mergedMode, () {
super.mergedMode = value;
});
}
late final _$_mergedAutoScrollAtom = Atom(
name: 'ChatTabsStoreBase._mergedAutoScroll',
context: context,
);
bool get mergedAutoScroll {
_$_mergedAutoScrollAtom.reportRead();
return super._mergedAutoScroll;
}
@override
bool get _mergedAutoScroll => mergedAutoScroll;
@override
set _mergedAutoScroll(bool value) {
_$_mergedAutoScrollAtom.reportWrite(value, super._mergedAutoScroll, () {
super._mergedAutoScroll = value;
});
}
late final _$_mergedRenderedMessagesAtom = Atom(
name: 'ChatTabsStoreBase._mergedRenderedMessages',
context: context,
);
List<MergedMessage> get mergedRenderedMessages {
_$_mergedRenderedMessagesAtom.reportRead();
return super._mergedRenderedMessages;
}
@override
List<MergedMessage> get _mergedRenderedMessages => mergedRenderedMessages;
@override
set _mergedRenderedMessages(List<MergedMessage> value) {
_$_mergedRenderedMessagesAtom.reportWrite(
value,
super._mergedRenderedMessages,
() {
super._mergedRenderedMessages = value;
},
);
}
late final _$ChatTabsStoreBaseActionController = ActionController(
name: 'ChatTabsStoreBase',
context: context,
);
@override
void _refreshMergedMessages() {
final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction(
name: 'ChatTabsStoreBase._refreshMergedMessages',
);
try {
return super._refreshMergedMessages();
} finally {
_$ChatTabsStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void toggleMergedMode() {
final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction(
name: 'ChatTabsStoreBase.toggleMergedMode',
);
try {
return super.toggleMergedMode();
} finally {
_$ChatTabsStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void resumeMergedScroll() {
final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction(
name: 'ChatTabsStoreBase.resumeMergedScroll',
);
try {
return super.resumeMergedScroll();
} finally {
_$ChatTabsStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void activateTab(int index) {
final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction(
@ -132,6 +255,18 @@ mixin _$ChatTabsStore on ChatTabsStoreBase, Store {
}
}
@override
void deactivateTab(int index) {
final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction(
name: 'ChatTabsStoreBase.deactivateTab',
);
try {
return super.deactivateTab(index);
} finally {
_$ChatTabsStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
bool removeTab(int index) {
final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction(
@ -157,12 +292,12 @@ mixin _$ChatTabsStore on ChatTabsStoreBase, Store {
}
@override
void setActiveTab(int index) {
void setActiveTab(int index, {bool silent = false}) {
final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction(
name: 'ChatTabsStoreBase.setActiveTab',
);
try {
return super.setActiveTab(index);
return super.setActiveTab(index, silent: silent);
} finally {
_$ChatTabsStoreBaseActionController.endAction(_$actionInfo);
}
@ -172,10 +307,14 @@ mixin _$ChatTabsStore on ChatTabsStoreBase, Store {
String toString() {
return '''
activeTabIndex: ${activeTabIndex},
mergedMode: ${mergedMode},
activeTab: ${activeTab},
activeChatStore: ${activeChatStore},
canAddTab: ${canAddTab},
showTabBar: ${showTabBar}
showTabBar: ${showTabBar},
mergedChannelIdToUserTwitch: ${mergedChannelIdToUserTwitch},
mergedMessages: ${mergedMessages},
mergedBufferCount: ${mergedBufferCount}
''';
}
}

View file

@ -0,0 +1,45 @@
import 'package:mobx/mobx.dart';
/// Tracks user-driven spotlight/hide selections for participant channels in
/// a Twitch shared chat session (multiple broadcasters merged onto one IRC
/// connection). Pure state so it can be unit-tested without the chat store.
///
/// [ObservableSet] is reactive on its own, so [Observer] widgets reading
/// [focusedChannelIds]/[hiddenChannelIds] rebuild without any MobX codegen.
class SharedChatBubbleState {
/// Channel ids the user has spotlighted (kept unfaded). Empty means no
/// spotlight is active and nothing is faded.
final focusedChannelIds = ObservableSet<String>();
/// Channel ids whose messages are filtered out of the feed entirely.
final hiddenChannelIds = ObservableSet<String>();
/// Adds/removes [channelId] from the spotlight set. No-ops for a hidden
/// channel — its messages are filtered out, so spotlighting it would fade
/// every other channel to showcase nothing.
void toggleFocus(String channelId) {
if (hiddenChannelIds.contains(channelId)) return;
if (!focusedChannelIds.remove(channelId)) {
focusedChannelIds.add(channelId);
}
}
/// Hides [channelId]'s messages and drops it from the spotlight set.
void hide(String channelId) {
hiddenChannelIds.add(channelId);
focusedChannelIds.remove(channelId);
}
/// Un-hides [channelId]'s messages.
void show(String channelId) => hiddenChannelIds.remove(channelId);
bool isFocused(String channelId) => focusedChannelIds.contains(channelId);
bool isHidden(String channelId) => hiddenChannelIds.contains(channelId);
/// Clears both sets (e.g. when a shared chat session ends).
void reset() {
focusedChannelIds.clear();
hiddenChannelIds.clear();
}
}

View file

@ -11,6 +11,7 @@ import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:frosty/widgets/blurred_container.dart';
import 'package:frosty/widgets/chat_input/emote_text_span_builder.dart';
import 'package:frosty/widgets/frosty_cached_network_image.dart';
import 'package:frosty/widgets/measure_size.dart';
class ChatBottomBar extends StatelessWidget {
final ChatStore chatStore;
@ -19,10 +20,14 @@ class ChatBottomBar extends StatelessWidget {
/// Passes this to ChatDetails to show "Add chat" option.
final VoidCallback onAddChat;
/// When set, indicates merged mode — shown in hint text (e.g., "Chat in xQc").
final String? channelDisplayName;
const ChatBottomBar({
super.key,
required this.chatStore,
required this.onAddChat,
this.channelDisplayName,
});
@override
@ -63,9 +68,11 @@ class ChatBottomBar extends StatelessWidget {
final isFullscreenOverlay =
chatStore.settings.fullScreen && context.isLandscape;
final effectiveChatDelay = chatStore.settings.effectiveChatDelay;
// Check if chat delay is active (for indicator only, doesn't block input)
final hasChatDelay =
chatStore.settings.showVideo && chatStore.settings.chatDelay > 0;
chatStore.settings.showVideo && effectiveChatDelay > 0;
const loginTooltipMessage = 'Log in to chat';
@ -299,9 +306,15 @@ class ChatBottomBar extends StatelessWidget {
: isWaitingForAck
? 'Sending...'
: chatStore.replyingToMessage != null
? 'Reply'
? channelDisplayName != null
? 'Reply in ${channelDisplayName!}'
: 'Reply'
: hasChatDelay
? 'Chat (${chatStore.settings.chatDelay.toInt()}s delay)'
? channelDisplayName != null
? 'Chat in ${channelDisplayName!} (${effectiveChatDelay.toInt()}s delay)'
: 'Chat (${effectiveChatDelay.toInt()}s delay)'
: channelDisplayName != null
? 'Chat in ${channelDisplayName!}'
: 'Chat',
),
controller: chatStore.textController,
@ -381,6 +394,13 @@ class ChatBottomBar extends StatelessWidget {
final needsBottomPadding =
!chatStore.assetsStore.showEmoteMenu && !isHorizontalLandscape;
// Measured once here; the list pads itself by this height so the bar
// never overlaps messages. Shared by both container branches below.
final measuredBar = MeasureSize(
onChange: (size) => chatStore.setBottomBarHeight(size.height),
child: bottomBarContent,
);
return isFullscreenOverlay
? Padding(
padding: EdgeInsets.only(
@ -388,7 +408,7 @@ class ChatBottomBar extends StatelessWidget {
? MediaQuery.of(context).padding.bottom
: 0,
),
child: bottomBarContent,
child: measuredBar,
)
: BlurredContainer(
gradientDirection: GradientDirection.down,
@ -397,7 +417,7 @@ class ChatBottomBar extends StatelessWidget {
? MediaQuery.of(context).padding.bottom
: 0,
),
child: bottomBarContent,
child: measuredBar,
);
},
);

View file

@ -5,9 +5,12 @@ import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/models/irc.dart';
import 'package:frosty/models/user.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart';
import 'package:frosty/screens/channel/chat/widgets/moderation_actions.dart';
import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:provider/provider.dart';
@ -15,16 +18,36 @@ class ChatMessage extends StatelessWidget {
final IRCMessage ircMessage;
final ChatStore chatStore;
final bool isModal;
final bool forceTimestamp;
final bool showReplyHeader;
final bool isInReplyThread;
/// Override for merged chat view — provides combined channel profiles.
final Map<String, UserTwitch>? overrideChannelIdToUserTwitch;
/// Override for merged chat view — sets the active/send-target channel ID.
final String? overrideCurrentChannelId;
/// In merged mode, the active tab's ChatStore for notifications and emote
/// menu. Composer actions (reply/paste) use [onActivateSourceTab] to switch
/// to the source tab first, then operate on [chatStore] directly.
final ChatStore? inputChatStore;
/// In merged mode, switches the active tab to this message's source channel.
final VoidCallback? onActivateSourceTab;
const ChatMessage({
super.key,
required this.ircMessage,
required this.chatStore,
this.isModal = false,
this.forceTimestamp = false,
this.showReplyHeader = true,
this.isInReplyThread = false,
this.overrideChannelIdToUserTwitch,
this.overrideCurrentChannelId,
this.inputChatStore,
this.onActivateSourceTab,
});
void onTapName(BuildContext context) {
@ -45,6 +68,7 @@ class ChatMessage extends StatelessWidget {
username: ircMessage.user!,
userId: ircMessage.tags['user-id']!,
displayName: ircMessage.tags['display-name']!,
onActivateSourceTab: onActivateSourceTab,
),
);
}
@ -64,23 +88,33 @@ class ChatMessage extends StatelessWidget {
username: user.login,
userId: user.id,
displayName: user.displayName,
onActivateSourceTab: onActivateSourceTab,
),
);
}
});
}
/// The store to use for notifications and emote menu in merged mode.
ChatStore get _effectiveInputStore => inputChatStore ?? chatStore;
Future<void> copyMessage() async {
await Clipboard.setData(ClipboardData(text: ircMessage.message ?? ''));
chatStore.updateNotification('Message copied');
_effectiveInputStore.updateNotification('Message copied');
}
void onLongPressMessage(BuildContext context, TextStyle defaultTextStyle) {
HapticFeedback.lightImpact();
// Allow the full menu for live messages and for ones marked deleted/cleared
// by a moderation action — those keep their tags (user-id, id), so a
// moderator can still act on them (e.g. remove a timeout). Everything else
// (notices, system messages) just copies.
if (ircMessage.command != Command.privateMessage &&
ircMessage.command != Command.userState) {
ircMessage.command != Command.userState &&
ircMessage.command != Command.clearChat &&
ircMessage.command != Command.clearMessage) {
copyMessage();
return;
}
@ -103,9 +137,14 @@ class ChatMessage extends StatelessWidget {
badgeScale: chatStore.settings.badgeScale,
launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType,
forceTimestamp: forceTimestamp,
showHistoricalTimestamps:
chatStore.settings.showHistoricalTimestamps,
channelIdToUserTwitch:
overrideChannelIdToUserTwitch ??
chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId: chatStore.channelId,
currentChannelId:
overrideCurrentChannelId ?? chatStore.channelId,
),
style: defaultTextStyle,
),
@ -124,13 +163,14 @@ class ChatMessage extends StatelessWidget {
onTap: () async {
await copyMessage();
final effectiveChatDelay = chatStore.settings.effectiveChatDelay;
final hasChatDelay =
chatStore.settings.showVideo &&
chatStore.settings.chatDelay > 0;
chatStore.settings.showVideo && effectiveChatDelay > 0;
if (hasChatDelay) {
chatStore.updateNotification(
'Chatting is disabled due to message delay (${chatStore.settings.chatDelay.toInt()}s)',
_effectiveInputStore.updateNotification(
'Chatting is disabled due to message delay (${effectiveChatDelay.toInt()}s)',
);
if (context.mounted) {
Navigator.pop(context);
@ -138,6 +178,10 @@ class ChatMessage extends StatelessWidget {
return;
}
// In merged mode, switch to the source tab so the paste
// targets the correct channel's composer.
onActivateSourceTab?.call();
// Paste the copied message into the text controller
chatStore.textController.text = ircMessage.message ?? '';
chatStore.safeRequestFocus();
@ -150,6 +194,11 @@ class ChatMessage extends StatelessWidget {
),
ListTile(
onTap: () {
// In merged mode, switch to the source tab so the reply
// targets the correct channel (reply-parent-msg-id is
// channel-scoped).
onActivateSourceTab?.call();
chatStore.replyingToMessage = ircMessage;
chatStore.safeRequestFocus();
Navigator.pop(context);
@ -157,6 +206,14 @@ class ChatMessage extends StatelessWidget {
leading: const Icon(Icons.reply),
title: const Text('Reply to message'),
),
if (ircMessage.tags['user-id'] != null)
ModerationActions(
chatStore: chatStore,
targetUserId: ircMessage.tags['user-id']!,
targetName:
ircMessage.tags['display-name'] ?? ircMessage.user ?? '',
messageId: ircMessage.tags['id'],
),
],
),
);
@ -200,9 +257,14 @@ class ChatMessage extends StatelessWidget {
badgeScale: chatStore.settings.badgeScale,
launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType,
forceTimestamp: forceTimestamp,
showHistoricalTimestamps:
chatStore.settings.showHistoricalTimestamps,
channelIdToUserTwitch:
overrideChannelIdToUserTwitch ??
chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId: chatStore.channelId,
currentChannelId:
overrideCurrentChannelId ?? chatStore.channelId,
),
),
);
@ -252,7 +314,7 @@ class ChatMessage extends StatelessWidget {
),
);
} else if (shouldHighlightFirstMessage) {
highlightColor = Colors.purple;
highlightColor = context.frostyColors.highlightedMessage;
messageHeaderIcon = Icon(
Icons.auto_awesome_rounded,
size: messageHeaderIconSize,
@ -266,7 +328,7 @@ class ChatMessage extends StatelessWidget {
),
);
} else if (shouldHighlightMessage) {
highlightColor = const Color(0xff9146ff);
highlightColor = context.frostyColors.highlightedMessage;
messageHeader = Text(
'Highlighted message',
style: TextStyle(
@ -277,7 +339,9 @@ class ChatMessage extends StatelessWidget {
}
// If user is being mentioned in the message, highlight it red.
if (ircMessage.mention == true) highlightColor = Colors.red;
if (ircMessage.mention == true) {
highlightColor = context.colorScheme.error;
}
if (messageHeader != null) {
renderMessage = Column(
@ -352,9 +416,14 @@ class ChatMessage extends StatelessWidget {
},
launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType,
forceTimestamp: forceTimestamp,
showHistoricalTimestamps:
chatStore.settings.showHistoricalTimestamps,
channelIdToUserTwitch:
overrideChannelIdToUserTwitch ??
chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId: chatStore.channelId,
currentChannelId:
overrideCurrentChannelId ?? chatStore.channelId,
),
),
),
@ -401,7 +470,7 @@ class ChatMessage extends StatelessWidget {
break;
case Command.userNotice:
if (chatStore.settings.showUserNotices) {
highlightColor = const Color(0xff9146ff);
highlightColor = context.frostyColors.highlightedMessage;
Widget? messageHeaderIcon;
Widget? messageHeader;
@ -443,10 +512,14 @@ class ChatMessage extends StatelessWidget {
messageHeaderIcon = Icon(
Icons.campaign_rounded,
size: messageHeaderIconSize,
color: messageHeaderTextColor,
);
messageHeader = const Text(
messageHeader = Text(
'Announcement',
style: TextStyle(fontWeight: messageHeaderFontWeight),
style: TextStyle(
fontWeight: messageHeaderFontWeight,
color: messageHeaderTextColor,
),
);
}
@ -475,9 +548,14 @@ class ChatMessage extends StatelessWidget {
badgeScale: chatStore.settings.badgeScale,
launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType,
forceTimestamp: forceTimestamp,
showHistoricalTimestamps:
chatStore.settings.showHistoricalTimestamps,
channelIdToUserTwitch:
overrideChannelIdToUserTwitch ??
chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId: chatStore.channelId,
currentChannelId:
overrideCurrentChannelId ?? chatStore.channelId,
),
),
),
@ -548,15 +626,24 @@ class ChatMessage extends StatelessWidget {
child: dividedMessage,
);
final shouldFade = chatStore.shouldFadeMessage(
sourceChannelId: ircMessage.sourceChannelId,
overrideCurrentChannelId: overrideCurrentChannelId,
);
final fadedMessage = shouldFade
? Opacity(opacity: 0.55, child: coloredMessage)
: coloredMessage;
final finalMessage = InkWell(
onTap: () {
FocusScope.of(context).unfocus();
if (chatStore.assetsStore.showEmoteMenu) {
chatStore.assetsStore.showEmoteMenu = false;
if (_effectiveInputStore.assetsStore.showEmoteMenu) {
_effectiveInputStore.assetsStore.showEmoteMenu = false;
}
},
onLongPress: () => onLongPressMessage(context, defaultTextStyle),
child: coloredMessage,
child: fadedMessage,
);
return finalMessage;

View file

@ -3,11 +3,11 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/models/user.dart';
import 'package:frosty/screens/channel/chat/chat.dart';
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
import 'package:frosty/screens/channel/chat/widgets/add_chat_dialog.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/widgets/frosty_dialog.dart';
import 'package:frosty/widgets/profile_picture.dart';
/// Widget that displays multiple chat tabs with a tab bar.
@ -39,7 +39,6 @@ class ChatTabs extends StatelessWidget {
);
if (!added && context.mounted) {
// If not added, it means the channel already exists (switched to it)
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Channel already open, switched to it'),
@ -50,21 +49,36 @@ class ChatTabs extends StatelessWidget {
}
}
/// Wraps a tab bar item (a tab chip or a shared-chat bubble) with its
/// trailing gap and centering, keyed for the reorderable list.
Widget _wrapItem({required Key key, required bool isLast, required Widget child}) {
return Padding(
key: key,
padding: EdgeInsets.only(right: isLast ? 0 : 4),
child: Center(child: child),
);
}
@override
Widget build(BuildContext context) {
return Observer(
builder: (context) {
final tabs = chatTabsStore.tabs;
final activeIndex = chatTabsStore.activeTabIndex;
final showTabBar = chatTabsStore.showTabBar;
final activeStore = chatTabsStore.activeChatStore;
// Twitch native shared chat participants (excluding the active
// channel itself, which is already shown as its own tab chip).
final sharedChatParticipants = activeStore.isInSharedChatMode
? activeStore.assetsStore.channelIdToUserTwitch.entries
.where((entry) => entry.key != activeStore.channelId)
.toList()
: const <MapEntry<String, UserTwitch>>[];
final showTabBar =
chatTabsStore.showTabBar || sharedChatParticipants.isNotEmpty;
final showMerge = tabs.where((t) => t.isActivated).length >= 2;
// Calculate extra top padding for tab bar when visible
final tabBarHeight = showTabBar ? 48.0 : 0.0;
// Get the top inset from listPadding (e.g., for AppBar in chat-only mode)
final topInset = listPadding?.resolve(TextDirection.ltr).top ?? 0;
// Adjust list padding to account for tab bar
final adjustedPadding = listPadding != null
? listPadding!.add(EdgeInsets.only(top: tabBarHeight))
: EdgeInsets.only(top: tabBarHeight);
@ -74,45 +88,43 @@ class ChatTabs extends StatelessWidget {
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
// On Android back gesture, close overlays first before navigating back
// Android back gesture: dismiss overlays before navigating away.
final activeStore = chatTabsStore.activeChatStore;
// Priority 1: Close emote menu
if (activeStore.assetsStore.showEmoteMenu) {
activeStore.assetsStore.showEmoteMenu = false;
return;
}
// Priority 2: Unfocus keyboard
if (activeStore.textFieldFocusNode.hasFocus) {
activeStore.unfocusInput();
return;
}
// Priority 3: Navigate back
Navigator.of(context).pop();
},
child: Stack(
children: [
// Chat content with IndexedStack to preserve state
Positioned.fill(
child: IndexedStack(
index: activeIndex,
children: tabs.map((tabInfo) {
// Show placeholder for non-activated tabs
if (tabInfo.chatStore == null) {
return const SizedBox.shrink();
}
return Chat(
key: ValueKey(tabInfo.channelId),
chatStore: tabInfo.chatStore!,
listPadding: adjustedPadding,
onAddChat: () => _handleAddChat(context),
);
}).toList(),
),
child: chatTabsStore.mergedMode
? Chat(
chatStore: chatTabsStore.activeChatStore,
chatTabsStore: chatTabsStore,
listPadding: adjustedPadding,
onAddChat: () => _handleAddChat(context),
)
: IndexedStack(
index: activeIndex,
children: tabs.map((tabInfo) {
if (tabInfo.chatStore == null) {
return const SizedBox.shrink();
}
return Chat(
key: ValueKey(tabInfo.channelId),
chatStore: tabInfo.chatStore!,
listPadding: adjustedPadding,
onAddChat: () => _handleAddChat(context),
);
}).toList(),
),
),
// Tab bar (only visible when more than 1 tab)
if (showTabBar)
Positioned(
top: topInset,
@ -120,30 +132,78 @@ class ChatTabs extends StatelessWidget {
right: 0,
child: SizedBox(
height: 48,
child: ReorderableListView.builder(
scrollDirection: Axis.horizontal,
itemCount: tabs.length,
padding: const EdgeInsets.symmetric(horizontal: 12),
onReorder: (oldIndex, newIndex) {
HapticFeedback.lightImpact();
chatTabsStore.reorderTab(oldIndex, newIndex);
},
proxyDecorator: (child, index, animation) {
return Material(
color: Colors.transparent,
child: child,
);
},
itemBuilder: (context, index) {
final tabInfo = chatTabsStore.tabs[index];
return Padding(
key: ValueKey(tabInfo.channelId),
padding: EdgeInsets.only(
right: index < tabs.length - 1 ? 4 : 0,
child: Stack(
children: [
// Right-clip past the merge button so chips slide
// under its left half then disappear.
Positioned(
left: 0,
top: 0,
bottom: 0,
right: showMerge ? 33 : 0,
child: ReorderableListView.builder(
scrollDirection: Axis.horizontal,
itemCount: tabs.length + sharedChatParticipants.length,
padding: EdgeInsets.only(
left: 12,
right: showMerge ? 32 : 12,
),
onReorderItem: (oldIndex, newIndex) {
// Shared chat bubbles are trailing, fixed-order
// items (not real tabs) — ignore any reorder
// that touches them.
if (oldIndex >= tabs.length ||
newIndex >= tabs.length) {
return;
}
HapticFeedback.lightImpact();
chatTabsStore.reorderTab(oldIndex, newIndex);
},
proxyDecorator: (child, index, animation) {
return Material(
color: Colors.transparent,
child: child,
);
},
itemBuilder: (context, index) {
final isLast =
index ==
tabs.length +
sharedChatParticipants.length -
1;
if (index < tabs.length) {
final tabInfo = chatTabsStore.tabs[index];
return _wrapItem(
key: ValueKey(tabInfo.channelId),
isLast: isLast,
child: _buildTab(context, index),
);
}
final participant =
sharedChatParticipants[index - tabs.length];
return _wrapItem(
key: ValueKey('shared_${participant.key}'),
isLast: isLast,
child: _buildSharedChatBubble(
context,
participant.key,
participant.value,
),
);
},
),
child: _buildTab(context, index),
);
},
),
if (showMerge)
Positioned(
right: 12,
top: 0,
bottom: 0,
child: Center(
child: _buildMoreActionsMenu(context),
),
),
],
),
),
),
@ -154,72 +214,329 @@ class ChatTabs extends StatelessWidget {
);
}
Future<void> _confirmRemoveTab(
BuildContext context,
int index,
String displayName,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => FrostyDialog(
title: 'Remove $displayName',
message: "Are you sure you want to remove $displayName's chat tab?",
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Remove'),
),
],
),
);
Widget _buildMoreActionsMenu(BuildContext context) {
final settings = chatTabsStore.activeChatStore.settings;
if (confirmed == true) {
HapticFeedback.lightImpact();
chatTabsStore.removeTab(index);
}
return _AnchoredPopupMenu(
itemsBuilder: (close) => [
Observer(
builder: (_) => CheckboxMenuButton(
value: chatTabsStore.mergedMode,
onChanged: (_) {
HapticFeedback.selectionClick();
chatTabsStore.toggleMergedMode();
},
child: const Text('Merge chats'),
),
),
Observer(
builder: (_) => CheckboxMenuButton(
value: settings.focusCurrentChannel,
onChanged: (newValue) {
HapticFeedback.selectionClick();
settings.focusCurrentChannel = newValue ?? false;
},
child: const Text('Focus current channel'),
),
),
],
anchorBuilder: (context, toggle) {
return IconButton.filledTonal(
icon: const Icon(Icons.more_vert, size: 18),
tooltip: 'Chat options',
visualDensity: VisualDensity.compact,
style: IconButton.styleFrom(
minimumSize: const Size(42, 42),
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
),
onPressed: () {
HapticFeedback.selectionClick();
toggle();
},
);
},
);
}
Widget _buildTab(BuildContext context, int index) {
final tabInfo = chatTabsStore.tabs[index];
final isActive = index == chatTabsStore.activeTabIndex;
final isActivated = tabInfo.isActivated;
final isSecondary = !tabInfo.isPrimary;
final displayName = getReadableName(
tabInfo.displayName,
tabInfo.channelLogin,
);
final avatar = ProfilePicture(userLogin: tabInfo.channelLogin, radius: 12);
return Observer(
builder: (context) {
final theme = Theme.of(context);
final isActivated = tabInfo.isActivated;
final hasUnread = chatTabsStore.hasUnreadMessages(index);
final dimmed = !isActivated || !(tabInfo.chatStore?.isConnected ?? false);
return InputChip(
avatar: isActivated ? avatar : Opacity(opacity: 0.5, child: avatar),
label: Text(
displayName,
style: isActivated
? null
: TextStyle(
color: Theme.of(
context,
).textTheme.bodyMedium?.color?.withValues(alpha: 0.5),
InputChip buildChip({VoidCallback? onDeleted}) => InputChip(
avatar: Badge(
smallSize: 8,
backgroundColor: theme.colorScheme.primary,
isLabelVisible: hasUnread,
child: Opacity(
opacity: dimmed ? 0.5 : 1.0,
child: ProfilePicture(
userLogin: tabInfo.channelLogin,
radius: 12,
),
),
),
),
selected: isActive,
showCheckmark: false,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onPressed: () {
if (!isActive) {
HapticFeedback.selectionClick();
chatTabsStore.setActiveTab(index);
}
label: Text(
displayName,
style: dimmed
? TextStyle(
color: theme.textTheme.bodyMedium?.color
?.withValues(alpha: 0.5),
)
: null,
),
selected: isActive,
showCheckmark: false,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onPressed: () {
if (isActive) return;
HapticFeedback.selectionClick();
chatTabsStore.setActiveTab(index);
},
onDeleted: onDeleted,
deleteButtonTooltipMessage:
onDeleted != null ? 'Tab options' : null,
);
if (!isSecondary) return buildChip();
return _AnchoredPopupMenu(
itemsBuilder: (close) => [
if (isActivated)
MenuItemButton(
leadingIcon: const Icon(Icons.power_off_rounded, size: 18),
child: const Text('Disconnect'),
onPressed: () {
HapticFeedback.lightImpact();
close();
chatTabsStore.deactivateTab(index);
},
),
MenuItemButton(
leadingIcon: Icon(
Icons.delete_outline_rounded,
size: 18,
color: theme.colorScheme.error,
),
child: Text(
'Remove',
style: TextStyle(color: theme.colorScheme.error),
),
onPressed: () {
HapticFeedback.lightImpact();
close();
chatTabsStore.removeTab(index);
},
),
],
anchorBuilder: (context, toggle) =>
buildChip(onDeleted: toggle),
);
},
);
}
/// Builds a bubble for a Twitch native shared chat participant channel.
/// Unlike manual tabs, these don't own a connection: tapping spotlights
/// the channel (unfades it, fading the rest — see [chat_message.dart]),
/// and the trailing menu hides/shows the channel's messages instead of
/// disconnecting/removing a tab.
Widget _buildSharedChatBubble(
BuildContext context,
String channelId,
UserTwitch user,
) {
final activeStore = chatTabsStore.activeChatStore;
final displayName = getReadableName(user.displayName, user.login);
return Observer(
builder: (context) {
final theme = Theme.of(context);
final isFocused = activeStore.sharedChatBubbles.isFocused(channelId);
final isHidden = activeStore.sharedChatBubbles.isHidden(channelId);
InputChip buildChip({VoidCallback? onDeleted}) => InputChip(
avatar: Opacity(
opacity: isHidden ? 0.5 : 1.0,
child: ProfilePicture(userLogin: user.login, radius: 12),
),
label: Text(
displayName,
style: isHidden
? TextStyle(
color: theme.textTheme.bodyMedium?.color
?.withValues(alpha: 0.5),
)
: null,
),
selected: isFocused,
showCheckmark: false,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onPressed: () {
HapticFeedback.selectionClick();
activeStore.sharedChatBubbles.toggleFocus(channelId);
},
onDeleted: onDeleted,
deleteButtonTooltipMessage:
onDeleted != null ? 'Channel options' : null,
);
return _AnchoredPopupMenu(
itemsBuilder: (close) => [
MenuItemButton(
leadingIcon: Icon(
isHidden
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
size: 18,
),
child: Text(isHidden ? 'Show channel' : 'Hide channel'),
onPressed: () {
HapticFeedback.lightImpact();
close();
if (isHidden) {
activeStore.sharedChatBubbles.show(channelId);
} else {
activeStore.sharedChatBubbles.hide(channelId);
}
},
),
],
anchorBuilder: (context, toggle) => buildChip(onDeleted: toggle),
);
},
onDeleted: tabInfo.isPrimary
? null
: () => _confirmRemoveTab(context, index, displayName),
deleteButtonTooltipMessage: 'Close chat',
);
}
}
/// 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),
),
);
}
}

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
import 'package:frosty/screens/channel/chat/widgets/moderation_actions.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:frosty/widgets/alert_message.dart';
@ -14,6 +15,7 @@ class ChatUserModal extends StatefulWidget {
final String username;
final String displayName;
final String userId;
final VoidCallback? onActivateSourceTab;
const ChatUserModal({
super.key,
@ -21,6 +23,7 @@ class ChatUserModal extends StatefulWidget {
required this.username,
required this.displayName,
required this.userId,
this.onActivateSourceTab,
});
@override
@ -61,6 +64,7 @@ class _ChatUserModalState extends State<ChatUserModal> {
IconButton(
tooltip: 'Reply',
onPressed: () {
widget.onActivateSourceTab?.call();
widget.chatStore.textController.text =
'@${widget.username} ';
Navigator.pop(context);
@ -77,6 +81,13 @@ class _ChatUserModalState extends State<ChatUserModal> {
name: name,
userLogin: widget.username,
userId: widget.userId,
// Moderator actions for this user (no messageId → no
// Delete); renders nothing for non-moderators.
moderationActions: ModerationActions(
chatStore: widget.chatStore,
targetUserId: widget.userId,
targetName: name,
),
),
),
icon: Icon(Icons.adaptive.more_rounded),
@ -115,6 +126,7 @@ class _ChatUserModalState extends State<ChatUserModal> {
ircMessage: userMessages[index],
chatStore: widget.chatStore,
isModal: true,
forceTimestamp: true,
),
),
),

View file

@ -0,0 +1,212 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/onboarding/login_webview.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
/// Moderator actions (delete / timeout / ban, plus unban) for [targetUserId],
/// reused by the message long-press menu and the user panel.
///
/// Renders nothing unless the viewer can moderate the channel, or should see
/// an opt-in row when IRC/broadcaster hints mod capability without OAuth scopes.
/// [messageId] enables the per-message Delete action; omit it (e.g. the user
/// panel, which isn't tied to one message) to hide Delete. The unban action
/// only appears when the user is currently banned/timed-out (seen via CLEARCHAT).
class ModerationActions extends StatelessWidget {
final ChatStore chatStore;
final String targetUserId;
/// Display name used in confirmation notifications.
final String targetName;
/// The message to delete; null hides the Delete action.
final String? messageId;
const ModerationActions({
super.key,
required this.chatStore,
required this.targetUserId,
required this.targetName,
this.messageId,
});
@override
Widget build(BuildContext context) {
return Observer(
builder: (context) {
final auth = chatStore.auth;
final hasModScopes = auth.hasModeratorScopes;
if (hasModScopes) {
if (auth.user.canPerformModeratorActions(chatStore.channelId)) {
return _actionsColumn(context);
}
return const SizedBox.shrink();
}
// IRC/broadcaster is a CTA hint only — not authority to call Helix.
// Read userState.mod only on the no-scopes path so USERSTATE acks do
// not rebuild this sheet while real actions are already available.
final isBroadcaster = auth.user.details?.id == chatStore.channelId;
final ircModHint = chatStore.userState.mod;
if (isBroadcaster || ircModHint) {
return _enableModeratorToolsRow(context, auth);
}
return const SizedBox.shrink();
},
);
}
Widget _enableModeratorToolsRow(BuildContext context, AuthStore auth) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Divider(),
ListTile(
onTap: () {
Navigator.pop(context);
// Use the root navigator after closing the sheet.
openEnableModeratorTools(auth);
},
leading: const Icon(Icons.shield_outlined),
title: const Text('Enable moderator tools…'),
subtitle: const Text(
'Authorize Twitch permissions to delete, timeout, and ban.',
),
),
],
);
}
Widget _actionsColumn(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Divider(),
// Compact one-row punitive actions, ordered least- to most-destructive.
Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 8),
child: Row(
children: [
if (messageId != null) ...[
Expanded(
child: _button(
Icons.delete_outline,
'Delete',
() => _run(context, _delete),
),
),
const SizedBox(width: 8),
],
Expanded(
child: _button(
Icons.timer_outlined,
'Timeout 10min',
() => _run(context, _timeout),
),
),
const SizedBox(width: 8),
Expanded(
child: _button(
Icons.block,
'Ban',
() => _run(context, _ban),
),
),
],
),
),
// Recovery action, separate from the punitive row to avoid mistaps and
// only shown when the user is actually banned/timed-out.
if (chatStore.isUserBannedOrTimedOut(targetUserId))
ListTile(
onTap: () => _run(context, _unban),
leading: const Icon(Icons.lock_open_rounded),
title: const Text('Remove ban or timeout'),
),
],
);
}
/// Compact icon-over-label button.
Widget _button(IconData icon, String label, VoidCallback onTap) {
return OutlinedButton(
onPressed: onTap,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 10),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 20),
const SizedBox(height: 4),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(label, style: const TextStyle(fontSize: 12)),
),
],
),
);
}
/// Closes the sheet, then runs [action], surfacing Twitch's reason on failure.
Future<void> _run(
BuildContext context,
Future<void> Function() action,
) async {
Navigator.pop(context);
try {
await action();
} on ApiException catch (e) {
chatStore.updateNotification(e.message);
}
}
Future<void> _delete() async {
final success = await chatStore.auth.user.deleteMessage(
broadcasterId: chatStore.channelId,
messageId: messageId!,
);
chatStore.updateNotification(
success ? 'Message deleted' : 'Failed to delete message',
);
}
Future<void> _timeout() async {
final success = await chatStore.auth.user.banOrTimeoutUser(
broadcasterId: chatStore.channelId,
userIdToBan: targetUserId,
duration: 600, // 10 minutes
);
chatStore.updateNotification(
success
? '$targetName timed out for 10 minutes.'
: 'Failed to timeout user',
);
}
Future<void> _ban() async {
final success = await chatStore.auth.user.banOrTimeoutUser(
broadcasterId: chatStore.channelId,
userIdToBan: targetUserId,
);
chatStore.updateNotification(
success ? '$targetName banned.' : 'Failed to ban user',
);
}
Future<void> _unban() async {
final success = await chatStore.auth.user.unbanUser(
broadcasterId: chatStore.channelId,
userIdToUnban: targetUserId,
);
if (success) chatStore.clearUserBanState(targetUserId);
chatStore.updateNotification(
success
? 'Removed ban/timeout for $targetName.'
: 'Failed to remove ban or timeout',
);
}
}

View file

@ -61,7 +61,7 @@ class ReplyThread extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: Text(
'$replyName: $replyBody',
style: const TextStyle(fontWeight: FontWeight.w500),
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
Flexible(

View file

@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
import 'package:frosty/screens/channel/video/video_player_interface.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/follow_button.dart';
/// Channel metadata + follow control shown under the player (portrait).
class ChannelInfoHeader extends StatelessWidget {
final VideoPlayerInterface videoStore;
final AuthStore authStore;
final String userId;
final String displayName;
final bool isInSharedChatMode;
const ChannelInfoHeader({
super.key,
required this.videoStore,
required this.authStore,
required this.userId,
required this.displayName,
this.isInSharedChatMode = false,
});
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).scaffoldBackgroundColor,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
child: Observer(
builder: (context) {
final streamInfo = videoStore.streamInfo;
final offline = streamInfo == null;
return Row(
spacing: 8,
children: [
Expanded(
child: StreamInfoBar(
streamInfo: streamInfo,
offlineChannelInfo: videoStore.offlineChannelInfo,
displayName: displayName,
isCompact: true,
isOffline: offline,
isInSharedChatMode: isInSharedChatMode,
showTextShadows: false,
tooltipTriggerMode: TooltipTriggerMode.longPress,
),
),
FollowButton(
authStore: authStore,
userId: userId,
displayName: displayName,
),
],
);
},
),
),
);
}
}

View 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;
}
}
}

View 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,
),
),
],
),
),
),
),
],
),
);
},
);
}
}

View file

@ -0,0 +1,8 @@
import 'package:frosty/screens/channel/video/video_player_interface.dart';
/// Capabilities that only the native player can meaningfully support.
/// Callers that need these must type-check against this interface.
abstract class NativeVideoPlayerInterface extends VideoPlayerInterface {
/// Whether the current quality is an HLS audio-only variant.
bool get isAudioOnlyMode;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,517 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'native_video_store.dart';
// **************************************************************************
// StoreGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
mixin _$NativeVideoStore on NativeVideoStoreBase, Store {
Computed<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 _$_blockingAdsAtom = Atom(
name: 'NativeVideoStoreBase._blockingAds',
context: context,
);
bool get blockingAds {
_$_blockingAdsAtom.reportRead();
return super._blockingAds;
}
@override
bool get _blockingAds => blockingAds;
@override
set _blockingAds(bool value) {
_$_blockingAdsAtom.reportWrite(value, super._blockingAds, () {
super._blockingAds = value;
});
}
late final _$_pausedAtom = Atom(
name: 'NativeVideoStoreBase._paused',
context: context,
);
bool get paused {
_$_pausedAtom.reportRead();
return super._paused;
}
@override
bool get _paused => paused;
@override
set _paused(bool value) {
_$_pausedAtom.reportWrite(value, super._paused, () {
super._paused = value;
});
}
late final _$_hasPlayedOnceAtom = Atom(
name: 'NativeVideoStoreBase._hasPlayedOnce',
context: context,
);
bool get hasPlayedOnce {
_$_hasPlayedOnceAtom.reportRead();
return super._hasPlayedOnce;
}
@override
bool get _hasPlayedOnce => hasPlayedOnce;
@override
set _hasPlayedOnce(bool value) {
_$_hasPlayedOnceAtom.reportWrite(value, super._hasPlayedOnce, () {
super._hasPlayedOnce = value;
});
}
late final _$_overlayVisibleAtom = Atom(
name: 'NativeVideoStoreBase._overlayVisible',
context: context,
);
bool get overlayVisible {
_$_overlayVisibleAtom.reportRead();
return super._overlayVisible;
}
@override
bool get _overlayVisible => overlayVisible;
@override
set _overlayVisible(bool value) {
_$_overlayVisibleAtom.reportWrite(value, super._overlayVisible, () {
super._overlayVisible = value;
});
}
late final _$_streamInfoAtom = Atom(
name: 'NativeVideoStoreBase._streamInfo',
context: context,
);
StreamTwitch? get streamInfo {
_$_streamInfoAtom.reportRead();
return super._streamInfo;
}
@override
StreamTwitch? get _streamInfo => streamInfo;
@override
set _streamInfo(StreamTwitch? value) {
_$_streamInfoAtom.reportWrite(value, super._streamInfo, () {
super._streamInfo = value;
});
}
late final _$_offlineChannelInfoAtom = Atom(
name: 'NativeVideoStoreBase._offlineChannelInfo',
context: context,
);
Channel? get offlineChannelInfo {
_$_offlineChannelInfoAtom.reportRead();
return super._offlineChannelInfo;
}
@override
Channel? get _offlineChannelInfo => offlineChannelInfo;
@override
set _offlineChannelInfo(Channel? value) {
_$_offlineChannelInfoAtom.reportWrite(value, super._offlineChannelInfo, () {
super._offlineChannelInfo = value;
});
}
late final _$_availableStreamQualitiesAtom = Atom(
name: 'NativeVideoStoreBase._availableStreamQualities',
context: context,
);
List<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}
''';
}
}

Some files were not shown because too many files have changed in this diff Show more