Compare commits

..

187 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
Tommy Chow
a0b46eaf3d fix overlay toggle stream reload, fix spring-back animation listener
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 23:19:13 -08:00
Tommy Chow
a3b8ac3237 clean up and harden webview video JS infrastructure
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 23:19:02 -08:00
Tommy Chow
93cd39c99f consolidate overlay shadow constants, simplify overlay gradients
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 23:18:54 -08:00
Tommy Chow
ed80799cae simplify agents md 2026-02-07 01:02:47 -08:00
Tommy Chow
baebafea59 overhaul tests 2026-02-07 00:51:22 -08:00
Tommy Chow
e58c9875e0 update ai files 2026-02-07 00:46:01 -08:00
Tommy Chow
54f0385be4 merge instructions into AGENTS.md 2026-01-11 16:59:33 -08:00
Tommy Chow
da2eeee518 update claude and copilot instructions for accuracy
- fix global stores documentation (add GlobalAssetsStore location, remove UserStore)
- correct error handling docs (typed exceptions via BaseApiClient, not Future.error)
- add missing lint rules (avoid_redundant_argument_values, unnecessary_parenthesis)
- fix emote factory methods (add all 4: fromTwitch, fromBTTV, fromFFZ, from7TV)
- update build_runner commands to modern dart run syntax
- add MobX reactions examples and API architecture section
- align commit convention with actual repo style (lowercase, no prefixes)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 04:26:05 -08:00
Tommy Chow
60f2a57abf enable code-simplifier plugin 2026-01-09 18:25:23 -05:00
Tommy Chow
7267e1d1f8 release v5.0.5 2026-01-09 02:32:59 -05:00
Tommy Chow
9306d9c48d fix useragent breaking login on ios 2026-01-09 02:14:52 -05:00
Tommy Chow
260348736d fix landscape bottom padding in chat bottom bar 2026-01-09 01:47:02 -05:00
Tommy Chow
f383c447f5 revert auto-fill safe area 2026-01-09 01:04:09 -05:00
Tommy Chow
5963a0ab3c fix draggable divider still active in fullscreen
fixes #486
2026-01-08 20:49:13 -05:00
Tommy Chow
9aea491966 fix chat text shadows causing frame drops 2026-01-08 19:12:34 -05:00
Tommy Chow
2bd5df4358 release v5.0.4 2026-01-08 17:38:19 -05:00
Tommy Chow
549a93b495 actually fix safe area filll resetting when moving to portrait 2026-01-08 17:24:30 -05:00
Tommy Chow
bde322fd09 fix safe area filll resetting when moving to portrait 2026-01-08 16:30:22 -05:00
Tommy Chow
ba7f68af1e fix google log in 2026-01-08 01:42:11 -05:00
Tommy Chow
487700f5b7 add device orientation detection for optimal landscape rotation 2026-01-08 00:06:18 -05:00
Tommy Chow
50c9bb2387 add native device orientation support, auto-fill safe area depending on orientation 2026-01-07 23:54:11 -05:00
Tommy Chow
57671967c9 fix ios landscape bottom safe area 2026-01-07 00:31:42 -05:00
Tommy Chow
82ab13b517 add swipe pip gesture support in landscape 2026-01-07 00:31:40 -05:00
Tommy Chow
bfc5e0e19b update default latency setting to false 2026-01-06 23:31:44 -05:00
Tommy Chow
86927b4cfc add release notes extraction for GitHub draft releases 2026-01-06 21:25:18 -05:00
Tommy Chow
fe48e5f156 release v5.0.3 2026-01-06 20:27:02 -05:00
Tommy Chow
71efc3cfa5 format 2026-01-06 20:22:14 -05:00
Tommy Chow
c378b9b2fd add connection state management to ChatStore and update UI feedback in chat 2026-01-06 19:42:09 -05:00
Tommy Chow
d296a20aef enhance reconnect message handling and improve countdown display in chat 2026-01-06 18:22:21 -05:00
Tommy Chow
7a3f78bc7a add message acknowledgment handling and update UI feedback for sending state 2026-01-06 16:59:16 -05:00
Tommy Chow
a43e453e7e fix send button broken in landscape mode
fixes #491
2026-01-06 15:33:20 -05:00
Tommy Chow
4466897d0a add claude code settings and update gitignore
Enable Claude Code plugins for project development workflow and track
settings.json while keeping local settings ignored.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 01:18:15 -05:00
Tommy Chow
6bf1af56a5 update podfile.lock 2026-01-06 00:28:16 -05:00
Tommy Chow
c0362b975f enhance latency tracking and memory management in video store 2026-01-06 00:21:06 -05:00
Tommy Chow
4d0b75a1c5 rename enhanced rendering setting to reset for all users 2026-01-05 23:40:31 -05:00
Tommy Chow
aaad50c10d release v5.0.2 2026-01-04 22:21:22 -05:00
Tommy Chow
04e076747b fix error message 2026-01-04 22:18:07 -05:00
Tommy Chow
08913c1e35 fix reply bar blocking bottom of chat, redesign 2026-01-04 21:36:00 -05:00
Tommy Chow
4a9678bb15 fix autocomplete bar blocking bottom of chat 2026-01-04 20:36:53 -05:00
Tommy Chow
bd194e91c2 fix chat delay message sending ux
refactor chat store to bypass delay for specific IRC commands and remove sending state management
2026-01-04 17:49:44 -05:00
Tommy Chow
fc56c3347a fix clear recent emotes button sizing 2026-01-04 16:35:43 -05:00
Tommy Chow
1c712659f1 fix black screen when logging in 2026-01-04 16:14:53 -05:00
Tommy Chow
cfc0e6187c tweak padding 2026-01-04 04:04:20 -05:00
Tommy Chow
a996c51ae3 update claude.md 2026-01-02 22:25:26 -05:00
Tommy Chow
23fee7bd4e add inline emote support in chat text field 2026-01-02 21:51:50 -05:00
Tommy Chow
359509ec52 refactor chat interaction handling, improve gesture detection, restore chat delay input indicator 2026-01-02 19:51:58 -05:00
Tommy Chow
8b54b3ba1b make search page type to search 2026-01-02 18:08:30 -05:00
Tommy Chow
1ebf6f32e0 release v5.0.1 2026-01-02 16:43:46 -05:00
Tommy Chow
69a7bcba92 make video enhanced rendering opt-out 2026-01-02 16:05:44 -05:00
Tommy Chow
0bdf9ab290 re-enable impeller on android 2026-01-02 15:51:09 -05:00
Tommy Chow
f3f5629b75 restore show latency setting
part of #485
2026-01-02 15:48:54 -05:00
Tommy Chow
ba28e09e58 downgrade flutter_secure_storage, safer async ops
potential fix for #484
2026-01-02 15:28:26 -05:00
dependabot[bot]
ef846a4596
Bump fastlane from 2.227.0 to 2.230.0 in /ios (#480)
Bumps [fastlane](https://github.com/fastlane/fastlane) from 2.227.0 to 2.230.0.
- [Release notes](https://github.com/fastlane/fastlane/releases)
- [Changelog](https://github.com/fastlane/fastlane/blob/master/CHANGELOG.latest.md)
- [Commits](https://github.com/fastlane/fastlane/compare/fastlane/2.227.0...fastlane/2.230.0)

---
updated-dependencies:
- dependency-name: fastlane
  dependency-version: 2.230.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tommy Chow <tommyxchow@gmail.com>
2026-01-02 01:29:24 -05:00
dependabot[bot]
dcd4aa61f7
Bump fastlane from 2.227.0 to 2.230.0 in /android (#479)
Bumps [fastlane](https://github.com/fastlane/fastlane) from 2.227.0 to 2.230.0.
- [Release notes](https://github.com/fastlane/fastlane/releases)
- [Changelog](https://github.com/fastlane/fastlane/blob/master/CHANGELOG.latest.md)
- [Commits](https://github.com/fastlane/fastlane/compare/fastlane/2.227.0...fastlane/2.230.0)

---
updated-dependencies:
- dependency-name: fastlane
  dependency-version: 2.230.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-02 01:29:00 -05:00
dependabot[bot]
4ea878a7f0
ci: bump actions/checkout from 4 to 6 in the actions group (#478)
Bumps the actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 4 to 6
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-02 01:28:44 -05:00
Tommy Chow
19641313bd update GitHub Actions and add Dependabot config 2026-01-02 01:22:11 -05:00
Tommy Chow
5f3b97b8f8 happy new year 2026-01-02 00:04:50 -05:00
Tommy Chow
a306edd698 update packages and versions 2026-01-01 23:17:43 -05:00
Tommy Chow
8f4ebde155
v5 (#463)
* migrate to dio

* ignore folders

* simplify twitch api headers handling

* clean up main

* simplify logs

* optimize profile picture fetching
fixes refetches on tab switch

* update claude md

* refactor irc.dart

* update copilot instructions with essential commands and project structure details

* implement message sending state management and UI updates

* refactor notification handling and add copy-paste functionality in chat

* refactor AlertMessage to support vertical layout and optional padding

* refactor error messages for consistency and clarity across various components

* refactor chat layout to use draggable divider and improve chat width settings

* refactor Uptime widget to use SharedTimerService for synchronized updates

* refactor search results components to handle null future states gracefully

* refactor loading indicators to use skeleton loaders for improved user experience

* refactor UI components for improved layout and responsiveness, enhance skeleton loaders, and streamline padding adjustments

* refactor: replace manual blur effects with BlurredContainer for consistency and reusability

* refactor: enhance JavaScript execution and error handling in WebView components

* refactor: replace app bar with blurred container for improved UI consistency across screens

* refactor: streamline video chat layout and enhance PiP drag gesture functionality

* revert some app bar changes

* refactor: implement Picture-in-Picture drag functionality in VideoChat

* refactor: pass currentChannelId to IRCMessage and ChatMessage for improved context

* fix large thubmnail width

* chore: update Gradle and Kotlin plugin versions for improved compatibility

* changes from v4.10.0 branch

* feat: integrate AnimatedScrollBorder for improved UI responsiveness across various screens

* refactor: update SectionHeader styling and improve theme management

* feat: add pinned section to StreamsList and enhance skeleton loader padding

* feat: add repository guidelines for project structure, coding style, testing, and security

* feat: enhance StreamCard UI with BlurredContainer and adjust border width in theme

* feat: implement unfocusInput and safeRequestFocus methods to manage text field focus behavior

* feat: enhance Picture-in-Picture drag interactions with haptic feedback and smooth animations

* refactor: improve code formatting and readability across multiple files

* feat: implement picture-in-picture mode toggle and state management for video playback

* feat: reset PiP mode state when pausing video playback

* feat: add custom app bar with profile picture and stream info for video chat

* feat: convert multiple settings screens to use StatefulWidget and add animated scroll borders

* feat: update blur configuration for adaptive alpha based on theme

* fix: adjust default font size in SectionHeader widget

* feat: enhance PiP mode with drag overlay instruction and UI improvements

* fix: improve badge rendering with consistent margin and border for current channel

* refactor: replace BackdropFilter with BlurredContainer for improved clarity and maintainability

* fix: reduce background transparency in Uptime widget for better visibility

* feat: enhance photo view dialog with full resolution toggle and blurred background

* fix: reset chat delay to 0 when auto sync is enabled and update settings title for clarity

* fix: adjust border width in theme to improve consistency

* feat: add forceDarkMode option to BlurredContainer for improved theme adaptability

* feat: implement offline followed channels feature with UI integration and settings option

* fix: increase font size and weight for offline status text for better readability

* feat: remove offline pinned channels feature and update related UI components

* feat: optimize offline channels loading behavior based on expansion state

* fix: improve clarity and consistency in repository guidelines formatting

* feat: update full resolution URL handling and button visibility logic

* feat: replace app bar with blurred version in chat-only mode and add animated scroll border

* feat: refactor video chat layout for improved chat and video display in landscape mode

* feat: remove bottom bar visibility setting and adjust chat layout accordingly

* fix: simplify condition for displaying offline channels section in following tab

* feat: enhance reply thread display with indentation and reply icon

* feat: implement showModalBottomSheetWithProperFocus for improved focus handling

* feat: add gradient direction support to BlurredContainer for enhanced visual effects

* feat: enhance NavigationBar and Theme settings for improved visual consistency

* feat: add gradient direction support to BlurredContainer in VideoChat for enhanced visual effects

* chore: update .gitignore to include .cursor file for better environment management

* fix: update PiP drag gesture conditions to include video paused state for better user experience

* refactor: replace ScrollController with SettingsPageLayout in settings screens for improved layout management

* refactor: centralize orientation handling by introducing OrientationUtils for improved readability and maintainability

* refactor: replace OrientationUtils with ContextExtensions for streamlined context-based access to theme, layout, and API dependencies

* refactor: improve video chat UI by adjusting title layout and adding stream info tooltips for better user experience

* refactor: adjust titleSpacing in theme and navigation components for improved layout consistency

* refactor: update stream info timer handling and overlay management for improved UI responsiveness

* refactor: enhance stream info timer logic and overlay visibility management for chat-only mode

* refactor: enhance input decoration theme in FrostyThemes for improved visual consistency and responsiveness

* refactor: adjust inputDecorationTheme padding in FrostyThemes for improved visual consistency

* refactor: enhance video overlay management by including video preview overlay in hideElements for improved UI consistency

* refactor: improve chat bottom bar padding and video chat layout for enhanced UI consistency

* refactor: adjust chat padding to account for device bottom insets for improved layout consistency

* refactor: update text styles in FrostyThemes for improved visual consistency across UI components

* refactor: update iOS minimum deployment target to 13.0 for improved compatibility

* refactor: adjust chat bottom padding based on emote menu visibility for improved layout consistency

* refactor: adjust padding values in StreamsList for improved layout consistency

* refactor: replace Padding with AnimatedPadding in Chat for smoother layout transitions
refactor: replace Container with AnimatedContainer in BlurredContainer for smoother animations

* refactor: remove unnecessary radius property from ProfilePicture in OfflineChannelCard for cleaner code

* refactor: enhance chat bottom bar suffix icon to display message delay with tooltip for improved user experience

* refactor: update hint text and tooltips in chat bottom bar and search for improved clarity

* refactor: simplify notification display logic and enhance chat input behavior for improved user experience

* refactor: enhance layout consistency by adjusting spacing in various components across the channel and home screens

* refactor: introduce LiveIndicator widget and adjust spacing in channel and video components for improved layout consistency

* refactor: remove monospace font family from chat bottom bar for improved text rendering

* refactor: reduce notification animation duration and change dismiss direction for improved user experience

* refactor: replace VideoBar with StreamInfoBar in video components for improved layout and consistency

* refactor: adjust font sizes and spacing in StreamInfoBar for improved layout consistency

* refactor: standardize font sizes and update gradient stops in video components for improved visual consistency

* refactor: update tooltip padding and background color in theme for improved visual consistency

* refactor: enhance tooltip behavior and standardize font sizes in StreamInfoBar and related components for improved visual consistency

* refactor: add showThumbnail option to LargeStreamCardSkeletonLoader for improved layout flexibility

* refactor: add StreamInfoBarSkeletonLoader for improved layout consistency in stream components

* refactor: add shadow effects to icons and text in StreamInfoBar and VideoOverlay for improved visual depth

* refactor: update defaultOverlayOpacity to 0.0 for improved overlay behavior

* fix: correct stream info text color on overlay

* refactor: implement a new color picker dialog with random color generation for enhanced user experience

* refactor: adjust gradient colors and stops in VideoOverlay for improved visual consistency

* refactor: refine gradient colors and stops in VideoOverlay for enhanced visual clarity

* refactor: add isCompact parameter to StreamInfoBar for responsive text sizing

* fix: update tooltip background color to use surface color for better contrast

* feat: add shared chat mode support in chat store and update StreamInfoBar for visual indication

* refactor: disable debug print for IRC messages to reduce log clutter

* feat: implement chat color selection feature with user color management in chat settings

* refactor: replace color adjustment logic with new contrast-aware utility for chat names

* refactor: simplify chat details UI by replacing list tiles with action cards and removing unused clear dialog

* refactor: enhance chat details UI by improving time formatting and adding label styling to action cards

* refactor: update chat color terminology to 'Username color' for clarity in UI

* refactor: enhance chat details UI by introducing active chat modes display and improving layout structure

* refactor: improve chat details layout by replacing ListTile with Padding and adding a Divider for better visual separation

* refactor: remove chat notifications positioning setting and adjust notification alignment in video chat

* refactor: streamline StreamInfoBar component by consolidating offline handling and improving layout for better readability

* refactor: enhance StreamInfoBar by adding tooltip for streamer name and improving text overflow handling

* refactor: adjust tooltip display duration in StreamInfoBar and update theme configuration

* refactor: adjust margin and padding in StreamCard for improved layout consistency

* chore: update dependencies in pubspec.yaml and pubspec.lock for Firebase and other packages

* template

* chore: update iOS platform version to 15.0 and Firebase dependencies to 12.2.0

* refactor: simplify StreamInfoBar layout by reducing nested widgets and improving text color handling

* format

* feat: enhance Channel model and StreamInfoBar to support offline channel details

* feat: add offline indicator and improve StreamInfoBar layout for better offline channel display

* fix: adjust icon color opacity in StreamInfoBar for improved visibility

* feat: enhance dismissal animation in FrostyPhotoViewDialog to track swipe direction

* docs: update copilot instructions for clarity and additional error handling details

* refactor: optimize latency tooltip layout and improve offline indicator styling in VideoOverlay

* feat: implement shared chat asset fetching and participant management in ChatAssetsStore

* refactor: replace Observer with Builder for improved widget rebuilding in VideoOverlay

* refactor: update StreamInfoBar border styling and adjust layout in VideoOverlay for improved visual consistency

* feat: add showTextShadows property to StreamInfoBar for customizable text shadow display

* refactor: adjust layout in VideoOverlay by adding padding to improve spacing and visual consistency

* refactor: enhance VideoOverlay gradients and layout for improved visual hierarchy and user experience

* refactor: reduce vertical padding in VideoOverlay for improved layout consistency

* refactor: remove overlay opacity settings from VideoSettings and SettingsStore for simplification

* refactor: improve text overlay transition in VideoChat for better user experience

* Revert "refactor: improve text overlay transition in VideoChat for better user experience"

This reverts commit fd16ff5ec3.

* refactor: wrap videoOverlay in ColoredBox for improved layout handling

* refactor: optimize animation duration and transition curves in VideoChat for smoother interactions

* refactor: dispose searchScrollController in HomeStoreBase to prevent memory leaks

* refactor: simplify gradient definitions in VideoOverlay for improved readability and maintainability

* refactor: remove 'Offline' text overlay from VideoOverlay for cleaner UI

* refactor: add refresh functionality for assets in ChatDetails with loading indicators

* refactor: update visibility conditions for uptime, viewer count, and category display in StreamInfoBar

* refactor: replace AlertDialog with FrostyDialog for consistent dialog styling across the application

* refactor: enhance layout handling in FrostyPageView to prevent overflow during height transitions

* refactor: replace ColorPickerDialog with AccentColorPickerDialog for improved color selection experience

* refactor: replace theme and accent color settings with dedicated components for improved modularity and readability

* refactor: replace ElevatedButton with TextButton for consistent button styling across dialogs

* refactor: integrate MobX Observer for reactive state management in settings widgets

* refactor: update theme and dialog components for improved styling and consistency

* refactor: enhance dialog styling with rounded corners and border in theme configuration

* refactor: improve dialog messages for unauthorized access handling

* refactor: update bodyLarge text style in theme configuration to include font weight

* refactor: standardize Divider usage and adjust theme styles for improved consistency

* refactor: adjust Divider properties and theme styles for enhanced visual consistency

* chore: remove GEMINI.md file as it is no longer needed for project documentation

* refactor: enhance token validation and reconnect logic in AuthBase for improved error handling

* format

* refactor: integrate landscape cutout settings into VideoChat layout for improved responsiveness

* refactor: implement app lifecycle handling in VideoChat and enhance stream info update logic in VideoStore

* refactor: update landscape cutout conditionals in VideoChat layout for improved responsiveness

* update AGENTS.md

* add scrollbar to search

* fix scrolling exception

* update CLAUDE.md

* 5 auth reconnect attemps then log out

* increase reply max lines to 2

* simplify and improve reply formatting

* add prime icon

* better draggable divider

* support draggable divider in fullscreen overlay chat

* add text shadow in fullscreen chat overlay mode

* format

* add landscape checks

* video store optimization attempts
* Dispose WebViewController and remove JavaScript channels properly
  * Track and cleanup all MutationObservers to prevent accumulation
  * Throttle latency updates to once per 5 seconds (reduces CPU load)
  * Add timeout protection and null checks for robustness
  * Prevent duplicate event listeners on page reloads

* double tap divider to reset

* Revert "video store optimization attempts"

This reverts commit b1df7b0de4.

* optimize video store attempt 2

* Revert "double tap divider to reset"

This reverts commit aeb26a4335.

* optimize latency listener to open the menu and check every 20 secconds

* more optimizations

* hard refresh

* abbreviate latency

* fix lint

* improve hide default overlay

* fix chat no longer reconnecting with chat delay

* refine chat alerts

* fix historical message missing badges and emotes on initial connection

* parallelize

* revert

* add reconnect button inline action

* refactor: replace Scrollbar with FrostyScrollbar across chat and settings screens for consistent styling

* tweak chat details menu

* add scrollbar to release notes, renames for consistency

* fix duplicate message case

* fix chat delay alerts in auto sync mode

* add regex for numeric strings and enhance display name formatting logic

* exterminate borders

* refactor: simplify active chat modes display in chat details screen

* feat: enhance chat input button with delay notification and disable state

* fix: wrap chat in SafeArea to prevent UI overflow issues

* refactor: remove divider line from video chat and update draggable divider visibility logic

* persist overlay in pip mode

* fix: update chat delay handling and notifications in chat bottom bar

* fix: update chat overlay visibility logic to respect fullscreen settings

* tap to show deleted message

* fix: prevent accidental chat scrolling when accessing system UI in landscape mode

* show latency in portrait

* fix: enhance chat delay notifications with dynamic delay time

* docs: update CLAUDE.md for improved environment variable instructions and architecture overview

* fix: improve chat delay countdown management and message handling

* add preliminary release notes

* remove agents.md

* update lockfile

* add initial multi chat feature

* add option to persist multi chat tabs

* add remove confirmation dialog

* improve add chat design

* lazy-load tabs

* create global assets store to dedupe global asset fetching

* improve tab design/styles

* update notes

* use frosty dialog

* update claude md

* tweak

* add ability to reorder tabs

* resolve conflict

* merge cleanup

* format

* update release notes

* add chat delay sync completion tracking to prevent countdown re-appearance

* add tests

* fix

* update release notes

* bump
2026-01-01 22:28:17 -05:00
Micah Morrison
b3af20d89a
support Twitch channel links (#473) 2026-01-01 15:02:08 -05:00
Corentin Detry
6fb0c75f38
fix: unfocus chat text input on tap outside (#459)
Fixes #458
2025-09-07 11:52:20 -07:00
Tommy Chow
6ad1361e30 format 2025-08-23 00:13:56 -07:00
Tommy Chow
25acfe9047 fix scroll controller errors 2025-08-23 00:13:50 -07:00
Tommy Chow
856f8aebd2 add ai files 2025-08-23 00:04:18 -07:00
Tommy Chow
b0d2b11622 update pubspec.lock 2025-08-22 21:44:42 -07:00
Tommy Chow
360358acaa delete old tests 2025-08-22 21:34:47 -07:00
Tommy Chow
d43fa5ffa2 update .gitignore, add CLAUDE.md 2025-08-22 21:34:25 -07:00
Tommy Chow
224be878c6 gen file 2025-08-22 21:32:30 -07:00
314 changed files with 46152 additions and 11099 deletions

5
.claude/CLAUDE.md Normal file
View file

@ -0,0 +1,5 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
@../AGENTS.md

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"

33
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,33 @@
version: 2
updates:
# Keep GitHub Actions up to date
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "ci"
groups:
actions:
patterns:
- "*"
# Keep Ruby gems (fastlane) up to date
- package-ecosystem: "bundler"
directory: "/android"
schedule:
interval: "monthly"
- package-ecosystem: "bundler"
directory: "/ios"
schedule:
interval: "monthly"
# Keep Dart/Flutter packages up to date
- package-ecosystem: "pub"
directory: "/"
schedule:
interval: "weekly"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]

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@v4
- 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

@ -19,11 +19,11 @@ jobs:
SECRET: ${{ secrets.SECRET }}
steps:
- name: Checkout Frosty repo
uses: actions/checkout@v4
uses: actions/checkout@v6
# Setup Java so that we can build the APK.
- name: Set up Java
uses: actions/setup-java@v3
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
@ -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.
@ -52,10 +53,53 @@ jobs:
flutter build apk --dart-define CLIENT_ID=$CLIENT_ID --dart-define SECRET=$SECRET
mv build/app/outputs/flutter-apk/app-release.apk frosty-${{ github.ref_name }}.apk
# Extract release notes from assets/release-notes.md for the current version.
- name: Extract release notes
id: release_notes
run: |
VERSION="${{ github.ref_name }}"
RELEASE_NOTES_FILE="assets/release-notes.md"
# Extract the section for this version using awk
# Starts at "## vX.X.X", skips date line, stops at next "---" or "## v" header
NOTES=$(awk -v version="$VERSION" '
BEGIN { found=0; output="" }
$0 ~ "^## " version "$" { found=1; next }
found && /^[A-Z][a-z]+ [0-9]+, [0-9]+$/ { next }
found && /^---$/ { exit }
found && /^## v[0-9]+\.[0-9]+\.[0-9]+$/ { exit }
found { output = output $0 "\n" }
END { print output }
' "$RELEASE_NOTES_FILE")
# Clean up "on GitHub" suffix (redundant since we're on GitHub)
NOTES=$(echo "$NOTES" | sed 's/ on GitHub)/)/')
# Trim leading/trailing whitespace
NOTES=$(echo "$NOTES" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
if [ -n "$NOTES" ]; then
# Get the previous tag for the Full Changelog link
PREV_TAG=$(git describe --tags --abbrev=0 "$VERSION^" 2>/dev/null || echo "")
echo "found=true" >> $GITHUB_OUTPUT
echo "body<<EOF" >> $GITHUB_OUTPUT
echo "$NOTES" >> $GITHUB_OUTPUT
if [ -n "$PREV_TAG" ]; then
echo "" >> $GITHUB_OUTPUT
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREV_TAG}...${VERSION}" >> $GITHUB_OUTPUT
fi
echo "EOF" >> $GITHUB_OUTPUT
else
echo "found=false" >> $GITHUB_OUTPUT
echo "::warning::No release notes found for $VERSION in $RELEASE_NOTES_FILE"
fi
# Create a draft release on GitHub.
- name: Release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v3
with:
draft: true
generate_release_notes: true
generate_release_notes: ${{ steps.release_notes.outputs.found != 'true' }}
body: ${{ steps.release_notes.outputs.found == 'true' && steps.release_notes.outputs.body || '' }}
files: frosty-${{ github.ref_name }}.apk

View file

@ -7,11 +7,37 @@ jobs:
name: Check and test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- 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

@ -29,18 +29,18 @@ jobs:
steps:
- name: Checkout Frosty repo
uses: actions/checkout@v4
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.2
ruby-version: 3.3
bundler-cache: true
# Setup Java so that we can build the appbundle.
- name: Set up Java
uses: actions/setup-java@v3
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
@ -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.
@ -82,9 +83,9 @@ jobs:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
steps:
- name: Checkout Frosty repo
uses: actions/checkout@v4
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).

11
.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/
@ -32,6 +32,7 @@ migrate_working_dir/
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
@ -43,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/
@ -62,3 +65,9 @@ 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"]
}
]
}

46
AGENTS.md Normal file
View file

@ -0,0 +1,46 @@
# AGENTS.md
Flutter Twitch client for iOS and Android. MobX with `build_runner`. The landing page is `web/` (own `AGENTS.md`). Flutter is pinned in `pubspec.yaml` `environment.flutter` — bump that one field; CI reads it via `flutter-version-file`.
## Commands
```bash
flutter pub get
flutter run --dart-define-from-file=.env # local; .env is gitignored
flutter run --dart-define=CLIENT_ID=ID --dart-define=SECRET=SECRET
flutter analyze
flutter test
flutter test test/path/to/file.dart
dart run build_runner build # after MobX / @JsonSerializable changes
```
`--delete-conflicting-outputs` is ignored as of build_runner 2.15. Don't add it.
## Layout
- `lib/apis/` — API clients extend `BaseApiClient`
- `lib/models/` — data models; `@JsonSerializable` ones commit `.g.dart`
- `lib/screens/{feature}/` — screens; feature stores in co-located `stores/`
- `lib/stores/` — global stores only (`global_assets_store.dart`)
- `test/` mirrors `lib/`; shared fixtures in `test/fixtures/`
Don't add top-level store files or standalone API classes. Canonical MobX store: `lib/screens/settings/stores/auth_store.dart`. Extract state, reactions, and actions into stores when it keeps widgets on rendering.
New or changed logic (stores, API clients, parsing, utils) ships with tests; extend existing tests when touching tested code. Pure UI or config may skip. HTTP: `http_mock_adapter` (`DioAdapter`) with full URLs. Otherwise `mocktail`.
## Gotchas
- Commit regenerated `.g.dart` files. Never edit them. CI fails the PR if they're stale.
- `main.dart` secure-storage `deleteAll()` looks redundant; it handles Android/iOS storage surviving uninstall. Don't remove it.
- `flutter_secure_storage` stays on v9 (v10 shipped in 5.0.0 and was reverted in 5.0.1 over an Android 14 startup freeze). That also holds `package_info_plus` v9 and `device_info_plus` v12 (win32). Don't jump them in a drive-by upgrade.
- Git-pinned forks in `pubspec.yaml` (`extended_text_field`, `better_native_video_player`, `simple_pip_mode`) carry patches. Don't swap them back to pub.dev.
- `android/gradle.properties`: keep `android.builtInKotlin=false` and `android.newDsl=false` so plugins that still apply KGP build on AGP 9.
- `ios/Runner/AppDelegate.swift` is customized (`CookieExtractorPlugin` + `FlutterImplicitEngineDelegate`). Flutter will not auto-migrate UIScene; don't replace the delegate with the template.
- `android/.../MainActivity.kt` extends `PipCallbackHelperActivityWrapper` and registers `CookieExtractorPlugin` by hand. Don't revert it to `FlutterActivity`.
- Helix OAuth (`Bearer`) and the Twitch web GQL cookie are different tokens. Native playback uses the cookie; don't send the Helix token to GQL/Usher.
- `syncedChatDelay` is runtime-only (`includeFromJson/toJson: false`). Don't persist it with the other settings.
- Don't remove the `ALLOW_DEBUG_SIGNED_RELEASE` branch in `android/app/build.gradle.kts`. Real releases must still fail if `key.properties` is missing.
## Releases
When cutting a store or GitHub release, read `docs/release.md` and follow it. That playbook is repo-local, not a global skill. `assets/release-notes.md` keeps about three consecutive versions; drop from the oldest end, never skip.

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

@ -11,6 +11,14 @@ include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- lib/**.g.dart
- .dart_tool/**
- build/**
- ios/**
- android/**
- web/**
- windows/**
- macos/**
- linux/**
linter:
# The lint rules applied to this project can be customized in the

View file

@ -1,48 +1,49 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.7)
base64
nkf
rexml
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.3.2)
aws-partitions (1.1082.0)
aws-sdk-core (3.222.1)
aws-eventstream (1.4.0)
aws-partitions (1.1260.0)
aws-sdk-core (3.252.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.99.0)
aws-sdk-core (~> 3, >= 3.216.0)
aws-sdk-kms (1.129.0)
aws-sdk-core (~> 3, >= 3.248.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.183.0)
aws-sdk-core (~> 3, >= 3.216.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.11.0)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
base64 (0.3.0)
benchmark (0.5.0)
bigdecimal (4.1.2)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.5)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.109.0)
faraday (1.10.4)
excon (0.112.0)
faraday (1.10.5)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
@ -54,32 +55,36 @@ GEM
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.7)
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (~> 1.0.0)
http-cookie (>= 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.1.0)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday-retry (1.0.4)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.227.0)
CFPropertyList (>= 2.3, < 4.0.0)
fastimage (2.4.1)
fastlane (2.234.0)
CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
aws-sdk-s3 (~> 1.197)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
base64 (~> 0.2)
benchmark (>= 0.1.0)
bundler (>= 1.17.3, < 5.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
@ -87,20 +92,24 @@ 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)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3)
naturally (~> 2.2)
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)
@ -111,43 +120,44 @@ GEM
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.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.29.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.6.1)
google-apis-iamcredentials_v1 (0.27.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.63.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.9.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.1)
google-cloud-storage (1.45.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.29.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)
@ -158,42 +168,41 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.7.6)
jwt (2.10.1)
json (2.19.9)
jwt (2.10.3)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.15.0)
multi_json (1.21.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.2.1)
nkf (0.2.0)
optparse (0.6.0)
naturally (2.3.0)
nkf (0.3.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
public_suffix (5.1.1)
rake (13.2.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)
rexml (3.4.1)
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.18.0)
signet (0.22.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
jwt (>= 1.5, < 4.0)
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
@ -203,7 +212,6 @@ GEM
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unf (0.2.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.27.0)

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"
@ -36,10 +37,16 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"/>
<data android:host="twitch.tv" />
<data android:host="www.twitch.tv" />
<data android:host="m.twitch.tv" />
</intent-filter>
</activity>
<meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="false" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

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=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
org.gradle.caching=true
org.gradle.parallel=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false

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.5-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.2.1" apply false
id "org.jetbrains.kotlin.android" version "2.0.21" 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,77 +1,28 @@
## v4.9.6
## v5.3.1
June 14, 2025
October 12, 2026
### Bug Fixes
- [Android] Fix missing stream quality options
- 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.
---
## v4.9.5
## v5.3.0
June 13, 2025
### Bug Fixes
- Fix stream latency no longer updating
---
## v4.9.4
June 6, 2025
### Bug Fixes
- Fix video stats blocking when using custom overlay
---
## v4.9.3
May 26, 2025
### Bug Fixes
- Fix missing 7TV emotes on some channels
- Fix sleep timer menu border styling
---
## v4.9.2
April 7, 2025
### Bug Fixes
- Fix large cache size (by @Artiu on GitHub)
---
## v4.9.1
April 4, 2025
### Bug Fixes
- [Android] Fix overlay latency and stream qualities, persist last chosen stream quality (by @Artiu on GitHub)
---
## v4.9.0
December 30, 2024
October 12, 2026
### New Features
- [Android] Added new "Use enhanced rendering" option in video settings
- Added a Follow / Unfollow button under the player (between video and chat) for live and offline channels.
---
## v5.2.9
October 7, 2026
### Bug Fixes
- Fixed incorrect badge order and spacing for FFZ bots
- Fixed missing emotes on channels that don't have 7TV enabled
#### See all release notes at 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>12.0</string>
</dict>
</plist>

View file

@ -1,48 +1,49 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.7)
base64
nkf
rexml
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.3.2)
aws-partitions (1.1082.0)
aws-sdk-core (3.222.1)
aws-eventstream (1.4.0)
aws-partitions (1.1260.0)
aws-sdk-core (3.252.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.99.0)
aws-sdk-core (~> 3, >= 3.216.0)
aws-sdk-kms (1.129.0)
aws-sdk-core (~> 3, >= 3.248.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.183.0)
aws-sdk-core (~> 3, >= 3.216.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.11.0)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
base64 (0.3.0)
benchmark (0.5.0)
bigdecimal (4.1.2)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.5)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.109.0)
faraday (1.10.4)
excon (0.112.0)
faraday (1.10.5)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
@ -54,32 +55,36 @@ GEM
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.7)
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (~> 1.0.0)
http-cookie (>= 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.1.0)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday-retry (1.0.4)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.227.0)
CFPropertyList (>= 2.3, < 4.0.0)
fastimage (2.4.1)
fastlane (2.234.0)
CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
aws-sdk-s3 (~> 1.197)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
base64 (~> 0.2)
benchmark (>= 0.1.0)
bundler (>= 1.17.3, < 5.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
@ -87,20 +92,24 @@ 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)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3)
naturally (~> 2.2)
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)
@ -111,43 +120,44 @@ GEM
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.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.29.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.6.1)
google-apis-iamcredentials_v1 (0.27.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.63.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.9.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.1)
google-cloud-storage (1.45.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.29.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)
@ -158,42 +168,41 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.7.6)
jwt (2.10.1)
json (2.19.9)
jwt (2.10.3)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.15.0)
multi_json (1.21.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.2.1)
nkf (0.2.0)
optparse (0.6.0)
naturally (2.3.0)
nkf (0.3.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
public_suffix (5.1.1)
rake (13.2.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)
rexml (3.4.1)
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.18.0)
signet (0.22.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
jwt (>= 1.5, < 4.0)
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
@ -203,7 +212,6 @@ GEM
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unf (0.2.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.27.0)
@ -225,6 +233,7 @@ PLATFORMS
universal-darwin-24
x86_64-darwin-20
x86_64-darwin-21
x86_64-linux
DEPENDENCIES
fastlane

View file

@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '12.0'
platform :ios, '15.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View file

@ -1,306 +1,28 @@
PODS:
- advanced_in_app_review (0.0.1):
- Flutter
- device_info_plus (0.0.1):
- Flutter
- Firebase/Analytics (11.10.0):
- Firebase/Core
- Firebase/Core (11.10.0):
- Firebase/CoreOnly
- FirebaseAnalytics (~> 11.10.0)
- Firebase/CoreOnly (11.10.0):
- FirebaseCore (~> 11.10.0)
- Firebase/Crashlytics (11.10.0):
- Firebase/CoreOnly
- FirebaseCrashlytics (~> 11.10.0)
- Firebase/Performance (11.10.0):
- Firebase/CoreOnly
- FirebasePerformance (~> 11.10.0)
- firebase_analytics (11.4.6):
- Firebase/Analytics (= 11.10.0)
- firebase_core
- Flutter
- firebase_core (3.13.1):
- Firebase/CoreOnly (= 11.10.0)
- Flutter
- firebase_crashlytics (4.3.6):
- Firebase/Crashlytics (= 11.10.0)
- firebase_core
- Flutter
- firebase_performance (0.10.1-6):
- Firebase/Performance (= 11.10.0)
- firebase_core
- Flutter
- FirebaseABTesting (11.10.0):
- FirebaseCore (~> 11.10.0)
- FirebaseAnalytics (11.10.0):
- FirebaseAnalytics/AdIdSupport (= 11.10.0)
- FirebaseCore (~> 11.10.0)
- FirebaseInstallations (~> 11.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0)
- FirebaseAnalytics/AdIdSupport (11.10.0):
- FirebaseCore (~> 11.10.0)
- FirebaseInstallations (~> 11.0)
- GoogleAppMeasurement (= 11.10.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0)
- FirebaseCore (11.10.0):
- FirebaseCoreInternal (~> 11.10.0)
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/Logger (~> 8.0)
- FirebaseCoreExtension (11.10.0):
- FirebaseCore (~> 11.10.0)
- FirebaseCoreInternal (11.10.0):
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- FirebaseCrashlytics (11.10.0):
- FirebaseCore (~> 11.10.0)
- FirebaseInstallations (~> 11.0)
- FirebaseRemoteConfigInterop (~> 11.0)
- FirebaseSessions (~> 11.0)
- GoogleDataTransport (~> 10.0)
- GoogleUtilities/Environment (~> 8.0)
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- FirebaseInstallations (11.10.0):
- FirebaseCore (~> 11.10.0)
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- PromisesObjC (~> 2.4)
- FirebasePerformance (11.10.0):
- FirebaseCore (~> 11.10.0)
- FirebaseInstallations (~> 11.0)
- FirebaseRemoteConfig (~> 11.0)
- FirebaseSessions (~> 11.0)
- GoogleDataTransport (~> 10.0)
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- nanopb (~> 3.30910.0)
- FirebaseRemoteConfig (11.10.0):
- FirebaseABTesting (~> 11.0)
- FirebaseCore (~> 11.10.0)
- FirebaseInstallations (~> 11.0)
- FirebaseRemoteConfigInterop (~> 11.0)
- FirebaseSharedSwift (~> 11.0)
- GoogleUtilities/Environment (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- FirebaseRemoteConfigInterop (11.13.0)
- FirebaseSessions (11.10.0):
- FirebaseCore (~> 11.10.0)
- FirebaseCoreExtension (~> 11.10.0)
- FirebaseInstallations (~> 11.0)
- GoogleDataTransport (~> 10.0)
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- nanopb (~> 3.30910.0)
- PromisesSwift (~> 2.1)
- FirebaseSharedSwift (11.13.0)
- Flutter (1.0.0)
- flutter_native_splash (2.4.3):
- Flutter
- flutter_secure_storage (6.0.0):
- Flutter
- GoogleAppMeasurement (11.10.0):
- GoogleAppMeasurement/AdIdSupport (= 11.10.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/AdIdSupport (11.10.0):
- GoogleAppMeasurement/WithoutAdIdSupport (= 11.10.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/WithoutAdIdSupport (11.10.0):
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- 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)
- 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`)
- 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`)
- 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
- GoogleAppMeasurement
- GoogleDataTransport
- GoogleUtilities
- nanopb
- PromisesObjC
- PromisesSwift
EXTERNAL SOURCES:
advanced_in_app_review:
:path: ".symlinks/plugins/advanced_in_app_review/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"
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
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
Firebase: 1fe1c0a7d9aaea32efe01fbea5f0ebd8d70e53a2
firebase_analytics: 943aa4699bf6afe056a96991ba8ee34776323d4c
firebase_core: 3c2f323cae65c97a636a05a23b17730ef93df2cf
firebase_crashlytics: 6f5f71ba7ec7b6a0412c4ed076272643c434f69f
firebase_performance: da79b34e2753057f7028c5e41ac5d8b3c5398eb2
FirebaseABTesting: dfc10eb6cc08fe3b391ac9e5aa40396d43ea6675
FirebaseAnalytics: 4e42333f02cf78ed93703a5c36f36dd518aebdef
FirebaseCore: 8344daef5e2661eb004b177488d6f9f0f24251b7
FirebaseCoreExtension: 6f357679327f3614e995dc7cf3f2d600bdc774ac
FirebaseCoreInternal: ef4505d2afb1d0ebbc33162cb3795382904b5679
FirebaseCrashlytics: 84b073c997235740e6a951b7ee49608932877e5c
FirebaseInstallations: 9980995bdd06ec8081dfb6ab364162bdd64245c3
FirebasePerformance: f0bd14be05aaa1136cbb1c9aaaf87d213d0e6fbf
FirebaseRemoteConfig: 10695bc0ce3b103e3706a5578c43f2a9f69d5aaa
FirebaseRemoteConfigInterop: 7915cec47731a806cda541f90898ad0fab8f9f86
FirebaseSessions: 9b3b30947b97a15370e0902ee7a90f50ef60ead6
FirebaseSharedSwift: aca73668bc95e8efccb618e0167eab05d19d3a75
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
GoogleAppMeasurement: 36684bfb3ee034e2b42b4321eb19da3a1b81e65d
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
in_app_review: a31b5257259646ea78e0e35fc914979b0031d011
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e
webview_flutter_wkwebview: a4af96a051138e28e29f60101d094683b9f82188
advanced_in_app_review: daebe4c801525b756fe589de60fd327cb12f965c
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
PODFILE CHECKSUM: c4c93c5f6502fe2754f48404d3594bf779584011
PODFILE CHECKSUM: 57c8aed26fba39d3ec9424816221f294a07c58eb
COCOAPODS: 1.16.2

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 @@
2025 Tommy Chow
2026 Tommy Chow

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

@ -0,0 +1,269 @@
import 'package:dio/dio.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
/// Common type aliases to reduce repetition
typedef JsonMap = Map<String, dynamic>;
typedef JsonList = List<dynamic>;
typedef QueryParams = Map<String, dynamic>;
typedef ApiHeaders = Map<String, String>;
/// Custom API exceptions for better error handling
class ApiException implements Exception {
final String message;
final int? statusCode;
final DioExceptionType? type;
const ApiException(this.message, [this.statusCode, this.type]);
@override
String toString() => 'ApiException: $message';
}
class NetworkException extends ApiException {
const NetworkException(String message)
: super(message, null, DioExceptionType.connectionError);
}
class TimeoutException extends ApiException {
const TimeoutException(String message)
: super(message, null, DioExceptionType.connectionTimeout);
}
class ServerException extends ApiException {
const ServerException(String message, int statusCode)
: super(message, statusCode, DioExceptionType.badResponse);
}
class NotFoundException extends ApiException {
const NotFoundException(String message)
: super(message, 404, DioExceptionType.badResponse);
}
class UnauthorizedException extends ApiException {
const UnauthorizedException(String message)
: super(message, 401, DioExceptionType.badResponse);
}
/// Twitch GQL rejected the request for a missing/invalid Client-Integrity token.
class IntegrityCheckException extends ApiException {
const IntegrityCheckException([
String message = 'Twitch integrity check failed',
]) : super(message);
}
/// Base API client to eliminate code duplication across API services
abstract class BaseApiClient {
final Dio _dio;
final String baseUrl;
const BaseApiClient(this._dio, this.baseUrl);
/// Builds the full URL, handling both relative endpoints and full URLs
String _buildUrl(String endpoint) {
// If endpoint is already a full URL (starts with http:// or https://), use it as-is
if (endpoint.startsWith('http://') || endpoint.startsWith('https://')) {
return endpoint;
}
// Otherwise, concatenate with base URL
return '$baseUrl$endpoint';
}
/// Generic GET request with common error handling
Future<T> get<T>(
String endpoint, {
QueryParams? queryParameters,
ApiHeaders? headers,
CancelToken? cancelToken,
}) async {
try {
final url = _buildUrl(endpoint);
final response = await _dio.get(
url,
queryParameters: queryParameters,
options: Options(headers: headers),
cancelToken: cancelToken,
);
return response.data as T;
} on DioException catch (e) {
throw _handleError(e);
}
}
/// Generic POST request with common error handling
Future<T> post<T>(
String endpoint, {
dynamic data,
QueryParams? queryParameters,
ApiHeaders? headers,
CancelToken? cancelToken,
}) async {
try {
final url = _buildUrl(endpoint);
final response = await _dio.post(
url,
data: data,
queryParameters: queryParameters,
options: Options(headers: headers),
cancelToken: cancelToken,
);
return response.data as T;
} on DioException catch (e) {
throw _handleError(e);
}
}
/// Generic PUT request with common error handling
Future<T> put<T>(
String endpoint, {
dynamic data,
QueryParams? queryParameters,
ApiHeaders? headers,
CancelToken? cancelToken,
}) async {
try {
final url = _buildUrl(endpoint);
final response = await _dio.put(
url,
data: data,
queryParameters: queryParameters,
options: Options(headers: headers),
cancelToken: cancelToken,
);
return response.data as T;
} on DioException catch (e) {
throw _handleError(e);
}
}
/// Generic DELETE request with common error handling
Future<T> delete<T>(
String endpoint, {
QueryParams? queryParameters,
ApiHeaders? headers,
CancelToken? cancelToken,
}) async {
try {
final url = _buildUrl(endpoint);
final response = await _dio.delete(
url,
queryParameters: queryParameters,
options: Options(headers: headers),
cancelToken: cancelToken,
);
return response.data as T;
} on DioException catch (e) {
throw _handleError(e);
}
}
/// Check if response indicates success (useful for status-only operations)
bool isSuccessResponse(Response response) {
return response.statusCode != null &&
response.statusCode! >= 200 &&
response.statusCode! < 300;
}
/// Converts DioException to appropriate ApiException subtype
ApiException _handleError(DioException error) {
final exception = switch (error.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.sendTimeout ||
DioExceptionType.receiveTimeout ||
DioExceptionType.transformTimeout =>
TimeoutException(_getTimeoutMessage(error.type)),
DioExceptionType.connectionError => NetworkException(
'No internet connection. Please check your network.',
),
DioExceptionType.badResponse => _handleHttpError(error),
DioExceptionType.cancel => ApiException('Request was cancelled'),
DioExceptionType.badCertificate =>
ApiException('Security certificate error'),
DioExceptionType.unknown =>
ApiException(error.message ?? 'An unexpected error occurred'),
};
// Record server errors and unexpected failures to Crashlytics.
// Skip timeouts, network errors (normal offline conditions), 401s, and cancellations.
if (exception is ServerException ||
error.type == DioExceptionType.unknown ||
error.type == DioExceptionType.badCertificate) {
try {
FirebaseCrashlytics.instance.recordError(
exception,
error.stackTrace,
reason: '${error.requestOptions.method} ${error.requestOptions.uri}',
);
} catch (_) {
// Firebase may not be initialized (e.g., in tests).
}
}
return exception;
}
/// Gets specific timeout message based on timeout type
String _getTimeoutMessage(DioExceptionType type) {
switch (type) {
case DioExceptionType.connectionTimeout:
return 'Connection timeout. Please try again.';
case DioExceptionType.sendTimeout:
return 'Upload timeout. Please try again.';
case DioExceptionType.receiveTimeout:
return 'Server response timeout. Please try again.';
case DioExceptionType.transformTimeout:
return 'Response processing timeout. Please try again.';
default:
return 'Request timeout. Please try again.';
}
}
/// Handles HTTP status code errors with specific exception types
ApiException _handleHttpError(DioException error) {
final statusCode = error.response?.statusCode;
final responseData = error.response?.data;
// Extract error message from server response
String? serverMessage;
if (responseData is JsonMap) {
serverMessage =
responseData['message'] as String? ??
responseData['error'] as String? ??
responseData['error_description'] as String?;
}
switch (statusCode) {
case 400:
return ApiException(serverMessage ?? 'Invalid request data');
case 401:
return UnauthorizedException('Please log in to continue');
case 403:
return ApiException('Access denied. Insufficient permissions');
case 404:
return NotFoundException(serverMessage ?? 'Resource not found');
case 409:
return ApiException('Conflict with current state. Please refresh');
case 422:
return ApiException(serverMessage ?? 'Invalid data provided');
case 429:
return ApiException(
'Rate limit exceeded. Please wait before trying again',
);
case 500:
return ServerException('Server error. Please try again later', 500);
case 502:
return ServerException('Service temporarily unavailable', 502);
case 503:
return ServerException(
'Service maintenance. Please try again later',
503,
);
case 504:
return ServerException('Server timeout. Please try again', 504);
default:
return ServerException(
serverMessage ?? 'HTTP Error $statusCode occurred',
statusCode ?? 500,
);
}
}
}

View file

@ -1,75 +1,55 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/models/badges.dart';
import 'package:frosty/models/emotes.dart';
import 'package:http/http.dart';
/// The BTTV service for making API calls.
class BTTVApi {
final Client _client;
const BTTVApi(this._client);
class BTTVApi extends BaseApiClient {
BTTVApi(Dio dio) : super(dio, 'https://api.betterttv.net/3/cached');
/// Returns a map of global BTTV emotes to their URL.
Future<List<Emote>> getEmotesGlobal() async {
final url = Uri.parse('https://api.betterttv.net/3/cached/emotes/global');
final data = await get<JsonList>('/emotes/global');
final response = await _client.get(url);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body) as List;
final emotes = decoded.map((emote) => EmoteBTTV.fromJson(emote)).toList();
final emotes = data.map((emote) => EmoteBTTV.fromJson(emote)).toList();
return emotes
.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvGlobal))
.toList();
} else {
return Future.error('Failed to get BTTV global emotes');
}
}
/// Returns a map of a channel's BTTV emotes to their URL.
Future<List<Emote>> getEmotesChannel({required String id}) async {
final url =
Uri.parse('https://api.betterttv.net/3/cached/users/twitch/$id');
final data = await get<JsonMap>('/users/twitch/$id');
final response = await _client.get(url);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
final result = EmoteBTTVChannel.fromJson(decoded);
final result = EmoteBTTVChannel.fromJson(data);
final emoteToUrl = <Emote>[];
emoteToUrl.addAll(
result.channelEmotes
.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvChannel)),
result.channelEmotes.map(
(emote) => Emote.fromBTTV(emote, EmoteType.bttvChannel),
),
);
emoteToUrl.addAll(
result.sharedEmotes
.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvShared)),
result.sharedEmotes.map(
(emote) => Emote.fromBTTV(emote, EmoteType.bttvShared),
),
);
return emoteToUrl;
} else {
return Future.error('Failed to get BTTV channel emotes');
}
}
/// Returns a map of username to their BTTV badge.
Future<Map<String, ChatBadge>> getBadges() async {
final url = Uri.parse('https://api.betterttv.net/3/cached/badges');
final data = await get<JsonList>('/badges');
final response = await _client.get(url);
if (response.statusCode == 200) {
final badges = jsonDecode(response.body) as List;
final badgeObjects =
badges.map((badge) => BadgeInfoBTTV.fromJson(badge)).toList();
final badgeObjects = data
.map((badge) => BadgeInfoBTTV.fromJson(badge))
.toList();
return {
for (final badge in badgeObjects)
badge.providerId: ChatBadge.fromBTTV(badge),
};
} else {
return Future.error('Failed to get BTTV badges');
}
}
}

71
lib/apis/dio_client.dart Normal file
View file

@ -0,0 +1,71 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:frosty/apis/firebase_performance_interceptor.dart';
/// Centralized Dio client configuration with interceptors and error handling
class DioClient {
static Dio createClient({List<Interceptor>? additionalInterceptors}) {
final dio = Dio();
// Optimized configuration based on 2024-2025 best practices
dio.options = BaseOptions(
connectTimeout: const Duration(
seconds: 8,
), // Balanced for various networks
receiveTimeout: const Duration(
seconds: 15,
), // Allow for larger API responses
sendTimeout: const Duration(seconds: 10), // Reasonable for uploads
headers: {
'User-Agent': 'Frosty (Flutter Twitch Client)',
'Accept': 'application/json',
'Content-Type': 'application/json',
'Connection': 'keep-alive', // Enable connection reuse
},
followRedirects: true,
maxRedirects: 3, // Reduced for better performance
persistentConnection: true, // Enable connection pooling
);
// Simple logging for debug builds
if (kDebugMode) {
dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
debugPrint('→ ${options.method} ${options.uri}');
handler.next(options);
},
onResponse: (response, handler) {
final statusCode = response.statusCode ?? 0;
final emoji = statusCode >= 200 && statusCode < 300 ? '✅' : '❌';
debugPrint(
'$emoji ${response.statusCode} ${response.requestOptions.method} ${response.requestOptions.uri}',
);
handler.next(response);
},
onError: (error, handler) {
debugPrint(
'❌ ${error.type.name} ${error.requestOptions.method} ${error.requestOptions.uri}',
);
if (error.response != null) {
debugPrint(' Status: ${error.response!.statusCode}');
}
handler.next(error);
},
),
);
}
// Trace HTTP requests as Firebase Performance metrics
dio.interceptors.add(FirebasePerformanceInterceptor());
// Add any additional interceptors provided
if (additionalInterceptors != null) {
for (final interceptor in additionalInterceptors) {
dio.interceptors.add(interceptor);
}
}
return dio;
}
}

View file

@ -1,49 +1,36 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/models/badges.dart';
import 'package:frosty/models/emotes.dart';
import 'package:http/http.dart';
/// The FFZ service for making API calls.
class FFZApi {
final Client _client;
const FFZApi(this._client);
class FFZApi extends BaseApiClient {
FFZApi(Dio dio) : super(dio, 'https://api.frankerfacez.com/v1');
/// Returns a map of global FFZ emotes to their URL.
Future<List<Emote>> getEmotesGlobal() async {
final url = Uri.parse('https://api.frankerfacez.com/v1/set/global');
final data = await get<JsonMap>('/set/global');
final response = await _client.get(url);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
final defaultSets = decoded['default_sets'] as List;
final defaultSets = data['default_sets'] as JsonList;
final emotes = <EmoteFFZ>[];
for (final setId in defaultSets) {
final emoticons =
decoded['sets'][setId.toString()]['emoticons'] as List;
final emoticons = data['sets'][setId.toString()]['emoticons'] as JsonList;
emotes.addAll(emoticons.map((emote) => EmoteFFZ.fromJson(emote)));
}
return emotes
.map((emote) => Emote.fromFFZ(emote, EmoteType.ffzGlobal))
.toList();
} else {
return Future.error('Failed to get FFZ global emotes');
}
}
/// Returns a channel's FFZ room info including custom badges and emote used.
Future<(RoomFFZ, List<Emote>)> getRoomInfo({required String id}) async {
final url = Uri.parse('https://api.frankerfacez.com/v1/room/id/$id');
final data = await get<JsonMap>('/room/id/$id');
final response = await _client.get(url);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
final roomInfo = RoomFFZ.fromJson(decoded['room']);
final roomInfo = RoomFFZ.fromJson(data['room']);
final emoticons =
decoded['sets'][roomInfo.set.toString()]['emoticons'] as List;
data['sets'][roomInfo.set.toString()]['emoticons'] as JsonList;
final emotes = emoticons.map((emote) => EmoteFFZ.fromJson(emote));
@ -51,27 +38,22 @@ class FFZApi {
roomInfo,
emotes
.map((emote) => Emote.fromFFZ(emote, EmoteType.ffzChannel))
.toList()
.toList(),
);
} else {
return Future.error('Failed to get FFZ room info');
}
}
/// 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 url = Uri.parse('https://api.frankerfacez.com/v1/badges/ids');
final data = await get<JsonMap>('/badges/ids');
final response = await _client.get(url);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
final badges = decoded['badges'] as List;
final badgeObjects =
badges.map((badge) => BadgeInfoFFZ.fromJson(badge)).toList();
final badges = data['badges'] as JsonList;
final badgeObjects = badges
.map((badge) => BadgeInfoFFZ.fromJson(badge))
.toList();
final result = <String, List<ChatBadge>>{};
for (final badge in badgeObjects.reversed) {
for (final userId in decoded['users'][badge.id.toString()]) {
for (final userId in data['users'][badge.id.toString()]) {
final entry = result[userId.toString()];
final normalBadge = ChatBadge.fromFFZ(badge);
if (entry == null) {
@ -83,8 +65,5 @@ class FFZApi {
}
return result;
} else {
return Future.error('Failed to get FFZ badges');
}
}
}

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

@ -1,52 +1,39 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/models/emotes.dart';
import 'package:http/http.dart';
/// The 7TV service for making API calls.
class SevenTVApi {
final Client _client;
const SevenTVApi(this._client);
class SevenTVApi extends BaseApiClient {
SevenTVApi(Dio dio) : super(dio, 'https://7tv.io/v3');
/// Returns a map of global 7TV emotes to their URL.
Future<List<Emote>> getEmotesGlobal() async {
final url = Uri.parse('https://7tv.io/v3/emote-sets/global');
final data = await get<JsonMap>('/emote-sets/global');
final response = await _client.get(url);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body)['emotes'] as List;
final decoded = data['emotes'] as JsonList;
final emotes = decoded.map((emote) => Emote7TV.fromJson(emote));
return emotes
.map((emote) => Emote.from7TV(emote, EmoteType.sevenTVGlobal))
.toList();
} else {
return Future.error('Failed to get 7TV global emotes');
}
}
/// Returns a tuple containing the emote set ID and a map of a channel's 7TV
/// emotes to their URL.
Future<(String, List<Emote>)> getEmotesChannel({required String id}) async {
final url = Uri.parse('https://7tv.io/v3/users/twitch/$id');
final data = await get<JsonMap>('/users/twitch/$id');
final response = await _client.get(url);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
final emoteSetId = decoded['emote_set']['id'] as String;
final emotes = (decoded['emote_set']['emotes'] as List)
.map((emote) => Emote7TV.fromJson(emote));
final emoteSetId = data['emote_set']['id'] as String;
final emotes = (data['emote_set']['emotes'] as JsonList).map(
(emote) => Emote7TV.fromJson(emote),
);
return (
emoteSetId,
emotes
.map((emote) => Emote.from7TV(emote, EmoteType.sevenTVChannel))
.where((emote) => emote.url.isNotEmpty)
.toList()
.toList(),
);
} else {
return Future.error('Failed to get 7TV channel emotes');
}
}
}

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';

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,31 @@
import 'package:dio/dio.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
/// Dio interceptor that automatically adds Twitch authentication headers
/// to requests targeting Twitch API endpoints.
class TwitchAuthInterceptor extends Interceptor {
final AuthStore _authStore;
const TwitchAuthInterceptor(this._authStore);
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
// Check if this is a Twitch API request that needs authentication
if (_shouldAddTwitchHeaders(options.uri)) {
// Add Twitch auth headers automatically
final twitchHeaders = _authStore.headersTwitch;
options.headers.addAll(twitchHeaders);
}
handler.next(options);
}
/// Determines if the request URL is for a Twitch API endpoint that requires authentication
bool _shouldAddTwitchHeaders(Uri uri) {
final url = uri.toString();
// Add headers for Twitch Helix API and OAuth endpoints
return url.startsWith('https://api.twitch.tv/helix') ||
url.startsWith('https://id.twitch.tv/oauth2');
}
}

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

@ -0,0 +1,143 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:frosty/main.dart';
import 'package:frosty/screens/onboarding/login_webview.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/utils/twitch_oauth_scopes.dart';
import 'package:frosty/widgets/frosty_dialog.dart';
import 'package:provider/provider.dart';
/// Dio interceptor that catches 401 Unauthorized errors and shows a login dialog
class UnauthorizedInterceptor extends Interceptor {
final AuthStore _authStore;
UnauthorizedInterceptor(this._authStore);
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
// Check if this is a 401 Unauthorized error on a Twitch Helix/OAuth request
if (err.response?.statusCode == 401 &&
_isTwitchHelixOrOAuth(err.requestOptions.uri)) {
// For token validation requests, let the error propagate so validateToken can handle it
if (err.requestOptions.uri.path.endsWith('/validate')) {
handler.next(err);
return;
}
final path = err.requestOptions.uri.path;
// Background moderated-channels fetches must never nag — especially for
// core-only tokens after an app upgrade.
if (isTwitchModeratedChannelsPath(path)) {
handler.reject(err);
return;
}
// Moderator actions without scopes → opt-in Enable flow, not a generic
// "missing permissions" re-login for core scopes.
if (isTwitchModeratorActionPath(path) &&
!_authStore.hasModeratorScopes) {
_showAuthDialog(
title: 'Enable moderator tools',
message: enableModeratorToolsDialogMessage(),
primaryLabel: 'Enable',
onPrimary: () => openEnableModeratorTools(_authStore),
);
handler.reject(err);
return;
}
// Core-only messaging: if core scopes are present, prefer "Session expired"
// over "Missing permissions" (401 ≠ always missing scope).
final isLoggedIn = _authStore.isLoggedIn;
final missingCore = missingTwitchScopes(
granted: _authStore.grantedScopes,
);
_showAuthDialog(
title: isLoggedIn && missingCore.isNotEmpty
? 'Missing permissions'
: 'Session expired',
message: unauthorizedDialogMessage(
isLoggedIn: isLoggedIn,
grantedScopes: _authStore.grantedScopes,
),
primaryLabel: 'Log in',
onPrimary: () {
final context = navigatorKey.currentContext;
if (context == null) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => Provider<AuthStore>.value(
value: _authStore,
child: const LoginWebView(),
),
),
);
},
);
handler.reject(err);
return;
}
// For non-401 errors, continue with normal error handling
handler.next(err);
}
void _showAuthDialog({
required String title,
required String message,
required String primaryLabel,
required VoidCallback onPrimary,
}) {
final context = navigatorKey.currentContext;
if (context == null) return;
if (_isDialogShowing) return;
_isDialogShowing = true;
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return FrostyDialog(
title: title,
message: message,
actions: [
TextButton(
onPressed: () {
_isDialogShowing = false;
Navigator.of(dialogContext).pop();
},
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
_isDialogShowing = false;
Navigator.of(dialogContext).pop();
onPrimary();
},
child: Text(primaryLabel),
),
],
);
},
).then((_) {
_isDialogShowing = false;
});
}
static bool _isDialogShowing = false;
bool _isTwitchHelixOrOAuth(Uri uri) {
final url = uri.toString();
return url.startsWith('https://api.twitch.tv/helix') ||
url.startsWith('https://id.twitch.tv/oauth2');
}
}
/// Helix path for listing channels the user moderates.
bool isTwitchModeratedChannelsPath(String path) =>
path.contains('/moderation/channels');
/// Helix paths for delete / ban / timeout / unban.
bool isTwitchModeratorActionPath(String path) =>
path.contains('/moderation/chat') || path.contains('/moderation/bans');

View file

@ -1,6 +1,20 @@
import 'package:flutter/material.dart';
/// Twitch API client ID.
const clientId = String.fromEnvironment('CLIENT_ID');
/// Twitch GQL client ID (web player, used for playback access tokens).
const twitchGqlClientId = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
/// SharedPreferences key for the per-channel last-selected stream quality.
/// A saved variant that doesn't exist on the target channel (e.g. 1080p on a
/// source-only channel) falls back to Auto via the quality-list lookup.
String lastStreamQualityKey(String userLogin) =>
'last_stream_quality_$userLogin';
/// Legacy global key. Cleared on startup during SharedPreferences init.
const kLegacyLastStreamQualityKey = 'last_stream_quality';
/// Twitch API client secret.
const secret = String.fromEnvironment('SECRET');
@ -19,6 +33,9 @@ const zeroWidthEmotes = [
/// Regex for matching strings that contain lower or upper case English characters.
final regexEnglish = RegExp(r'[a-zA-Z]');
/// Regex for matching strings that contain only numeric characters.
final regexNumbersOnly = RegExp(r'^\d+$');
/// Regex for matching URLs and file names in text.
final regexLink = RegExp(
r'(?<![A-Za-z0-9_.-])' // left boundary
@ -37,8 +54,49 @@ 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;
/// The default emote width and height when none are provided.
const defaultEmoteSize = 28.0;
/// Available named chat colors for Twitch users.
const chatColorNames = [
'blue',
'blue_violet',
'cadet_blue',
'chocolate',
'coral',
'dodger_blue',
'firebrick',
'golden_rod',
'green',
'hot_pink',
'orange_red',
'red',
'sea_green',
'spring_green',
'yellow_green',
];
/// Color values for the named chat colors.
const Map<String, Color> chatColorValues = {
'blue': Color(0xFF0000FF),
'blue_violet': Color(0xFF8A2BE2),
'cadet_blue': Color(0xFF5F9EA0),
'chocolate': Color(0xFFD2691E),
'coral': Color(0xFFFF7F50),
'dodger_blue': Color(0xFF1E90FF),
'firebrick': Color(0xFFB22222),
'golden_rod': Color(0xFFDAA520),
'green': Color(0xFF008000),
'hot_pink': Color(0xFFFF69B4),
'orange_red': Color(0xFFFF4500),
'red': Color(0xFFFF0000),
'sea_green': Color(0xFF2E8B57),
'spring_green': Color(0xFF00FF7F),
'yellow_green': Color(0xFF9ACD32),
};

View file

@ -1,58 +1,125 @@
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';
import 'package:frosty/screens/onboarding/onboarding_intro.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/stores/global_assets_store.dart';
import 'package:frosty/theme.dart';
import 'package:frosty/utils.dart';
import 'package:http/http.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:mobx/mobx.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
/// Errors that are expected on a mobile client — transient network/I/O, plugin
/// teardown races, and image fetch failures — and therefore shouldn't be
/// reported to Crashlytics as fatal crashes (which would tank the crash-free
/// metric and bucket unrelated errors together under the global handlers).
///
/// This is a last-resort net. The layers that own these errors already record
/// them non-fatally with precise reasons (e.g. base_api_client, the IRC
/// websocket handlers); this only catches what escapes that local handling.
bool _isNonFatalError(Object error) {
return error is SocketException ||
error is HttpException ||
error is WebSocketChannelException ||
error is ApiException ||
error is MissingPluginException ||
error is NetworkImageLoadException ||
(error is DioException && error.type != DioExceptionType.badResponse);
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
CustomCacheManager.removeOrphanedCacheFiles();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
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.
// If first time running app, will clear all tokens in the secure storage.
// Run non-blocking to avoid ANR on slow Android devices (especially Android 14).
if (firstRun) {
debugPrint('Clearing secure storage...');
const storage = FlutterSecureStorage();
await storage.deleteAll();
// Don't block - run in background after app starts.
// This is safe because first run means no active session depends on this data.
unawaited(
storage.deleteAll().catchError((e) {
debugPrint('Error clearing secure storage: $e');
}),
);
}
await initUtils();
@ -64,30 +131,70 @@ 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)));
/// Initialize API services with a common client.
/// Initialize API services with a common Dio client.
/// This will prevent every request from creating a new client instance.
final client = Client();
final twitchApiService = TwitchApi(client);
final bttvApiService = BTTVApi(client);
final ffzApiService = FFZApi(client);
final sevenTVApiService = SevenTVApi(client);
final dioClient = DioClient.createClient();
// Create API services
final twitchApiService = TwitchApi(dioClient);
final bttvApiService = BTTVApi(dioClient);
final ffzApiService = FFZApi(dioClient);
final sevenTVApiService = SevenTVApi(dioClient);
final twitchGqlApiService = TwitchGqlApi(dioClient);
// Create global assets store (shared cache for global emotes/badges)
final globalAssetsStore = GlobalAssetsStore(
twitchApi: twitchApiService,
bttvApi: bttvApiService,
ffzApi: ffzApiService,
sevenTVApi: sevenTVApiService,
);
// Create and initialize the authentication store
final authStore = AuthStore(twitchApi: twitchApiService);
final authStore = AuthStore(
twitchApi: twitchApiService,
twitchGqlApi: twitchGqlApiService,
);
// Add the auth interceptor to the Dio client after AuthStore creation
dioClient.interceptors.add(TwitchAuthInterceptor(authStore));
// Add the unauthorized interceptor to catch 401 errors
dioClient.interceptors.add(UnauthorizedInterceptor(authStore));
await authStore.init();
FirebaseCrashlytics.instance.setCustomKey('is_logged_in', authStore.isLoggedIn);
if (authStore.isLoggedIn && authStore.user.details != null) {
FirebaseCrashlytics.instance.setUserIdentifier(authStore.user.details!.id);
}
runApp(
MultiProvider(
providers: [
Provider<AuthStore>(create: (_) => authStore),
Provider<SettingsStore>(create: (_) => settingsStore),
Provider<TwitchApi>(create: (_) => twitchApiService),
Provider<BTTVApi>(create: (_) => bttvApiService),
Provider<FFZApi>(create: (_) => ffzApiService),
Provider<SevenTVApi>(create: (_) => sevenTVApiService),
Provider<AuthStore>.value(value: authStore),
Provider<SettingsStore>.value(value: settingsStore),
Provider<TwitchApi>.value(value: twitchApiService),
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),
),
@ -100,16 +207,16 @@ final navigatorKey = GlobalKey<NavigatorState>();
class MyApp extends StatefulWidget {
final bool firstRun;
const MyApp({
super.key,
this.firstRun = false,
});
const MyApp({super.key, this.firstRun = false});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
final AppLinks _appLinks = AppLinks();
StreamSubscription<Uri>? _linkSubscription;
@override
void initState() {
super.initState();
@ -120,6 +227,8 @@ class _MyAppState extends State<MyApp> {
.setMinLaunchTimes(5)
.setMinSecondsBeforeShowDialog(3)
.monitor();
_initDeepLinks();
}
@override
@ -127,12 +236,11 @@ class _MyAppState extends State<MyApp> {
return Observer(
builder: (context) {
final settingsStore = context.read<SettingsStore>();
final themes =
FrostyThemes(colorSchemeSeed: Color(settingsStore.accentColor));
final themes = FrostyThemes(
colorSchemeSeed: Color(settingsStore.accentColor),
);
return Provider<FrostyThemes>(
create: (_) => themes,
child: MaterialApp(
return MaterialApp(
title: 'Frosty',
theme: themes.light,
darkTheme: themes.dark,
@ -141,11 +249,101 @@ class _MyAppState extends State<MyApp> {
: settingsStore.themeType == ThemeType.light
? ThemeMode.light
: ThemeMode.dark,
navigatorObservers: [
FirebaseAnalyticsObserver(
analytics: FirebaseAnalytics.instance,
),
],
home: widget.firstRun ? const OnboardingIntro() : const Home(),
navigatorKey: navigatorKey,
),
);
},
);
}
Future<void> _initDeepLinks() async {
try {
// Handle links when app is already open
_linkSubscription = _appLinks.uriLinkStream.listen((uri) {
handleDeepLink(uri);
});
// Handle the initial link if app was opened from a link.
// Add timeout to prevent indefinite blocking on certain Android lifecycle states.
final initialLink = await _appLinks.getInitialLink().timeout(
const Duration(seconds: 3),
onTimeout: () => null,
);
if (initialLink != null) {
handleDeepLink(initialLink);
}
} catch (e) {
debugPrint('Failed to initialize deep links: $e');
}
}
Future<void> handleDeepLink(Uri uri) async {
final failureSnackbar = SnackBar(
content: AlertMessage(
message: 'Unable to navigate to \'$uri\'',
centered: false,
trailingIcon: Icons.open_in_browser_rounded,
// Fallback, allow user to open URL outside app
onTrailingIconPressed: () async {
await launchUrl(
uri,
mode: LaunchMode.inAppWebView, // Force browser
);
},
),
);
// Handle channel links
if (uri.pathSegments.isNotEmpty) {
final channelName = uri.pathSegments.first;
try {
final twitchApi = context.read<TwitchApi>();
final user = await twitchApi.getUser(userLogin: channelName);
final route = MaterialPageRoute(
settings: const RouteSettings(name: VideoChat.routeName),
builder: (context) => VideoChat(
userId: user.id,
userName: user.displayName,
userLogin: user.login,
),
);
if (navigatorKey.currentState == null) return;
WidgetsBinding.instance.addPostFrameCallback(
(_) => navigatorKey.currentState?.push(route),
);
} catch (e) {
// If we get here, there was most likely an error with the Twitch API call and/or this isn't really a channel link
debugPrint('Failed to open link $uri due to error: $e');
if (navigatorKey.currentContext == null) return;
ScaffoldMessenger.of(
navigatorKey.currentContext!,
).showSnackBar(failureSnackbar);
}
}
// TODO: Here we can implement handlers for other types of links
else {
// If we get here, it's a link format that we're unable to handle
if (navigatorKey.currentContext == null) return;
ScaffoldMessenger.of(
navigatorKey.currentContext!,
).showSnackBar(failureSnackbar);
}
}
@override
void dispose() {
_linkSubscription?.cancel();
super.dispose();
}
}

View file

@ -35,12 +35,7 @@ class BadgeInfoFFZ {
final String color;
final BadgeUrlsFFZ urls;
const BadgeInfoFFZ(
this.id,
this.title,
this.color,
this.urls,
);
const BadgeInfoFFZ(this.id, this.title, this.color, this.urls);
factory BadgeInfoFFZ.fromJson(Map<String, dynamic> json) =>
_$BadgeInfoFFZFromJson(json);
@ -55,11 +50,7 @@ class BadgeUrlsFFZ {
@JsonKey(name: '4')
final String url4x;
const BadgeUrlsFFZ(
this.url1x,
this.url2x,
this.url4x,
);
const BadgeUrlsFFZ(this.url1x, this.url2x, this.url4x);
factory BadgeUrlsFFZ.fromJson(Map<String, dynamic> json) =>
_$BadgeUrlsFFZFromJson(json);
@ -71,11 +62,7 @@ class BadgeInfo7TV {
final List<List<String>> urls;
final List<String> users;
BadgeInfo7TV(
this.tooltip,
this.urls,
this.users,
);
BadgeInfo7TV(this.tooltip, this.urls, this.users);
factory BadgeInfo7TV.fromJson(Map<String, dynamic> json) =>
_$BadgeInfo7TVFromJson(json);
@ -86,10 +73,7 @@ class BadgeInfoBTTV {
final String providerId;
final BadgeDetailsBTTV badge;
BadgeInfoBTTV(
this.providerId,
this.badge,
);
BadgeInfoBTTV(this.providerId, this.badge);
factory BadgeInfoBTTV.fromJson(Map<String, dynamic> json) =>
_$BadgeInfoBTTVFromJson(json);
@ -100,10 +84,7 @@ class BadgeDetailsBTTV {
final String description;
final String svg;
BadgeDetailsBTTV(
this.description,
this.svg,
);
BadgeDetailsBTTV(this.description, this.svg);
factory BadgeDetailsBTTV.fromJson(Map<String, dynamic> json) =>
_$BadgeDetailsBTTVFromJson(json);

View file

@ -21,13 +21,10 @@ BadgeInfoFFZ _$BadgeInfoFFZFromJson(Map<String, dynamic> json) => BadgeInfoFFZ(
json['title'] as String,
json['color'] as String,
BadgeUrlsFFZ.fromJson(json['urls'] as Map<String, dynamic>),
);
);
BadgeUrlsFFZ _$BadgeUrlsFFZFromJson(Map<String, dynamic> json) => BadgeUrlsFFZ(
json['1'] as String,
json['2'] as String,
json['4'] as String,
);
BadgeUrlsFFZ _$BadgeUrlsFFZFromJson(Map<String, dynamic> json) =>
BadgeUrlsFFZ(json['1'] as String, json['2'] as String, json['4'] as String);
BadgeInfo7TV _$BadgeInfo7TVFromJson(Map<String, dynamic> json) => BadgeInfo7TV(
json['tooltip'] as String,
@ -35,7 +32,7 @@ BadgeInfo7TV _$BadgeInfo7TVFromJson(Map<String, dynamic> json) => BadgeInfo7TV(
.map((e) => (e as List<dynamic>).map((e) => e as String).toList())
.toList(),
(json['users'] as List<dynamic>).map((e) => e as String).toList(),
);
);
BadgeInfoBTTV _$BadgeInfoBTTVFromJson(Map<String, dynamic> json) =>
BadgeInfoBTTV(
@ -44,7 +41,4 @@ BadgeInfoBTTV _$BadgeInfoBTTVFromJson(Map<String, dynamic> json) =>
);
BadgeDetailsBTTV _$BadgeDetailsBTTVFromJson(Map<String, dynamic> json) =>
BadgeDetailsBTTV(
json['description'] as String,
json['svg'] as String,
);
BadgeDetailsBTTV(json['description'] as String, json['svg'] as String);

View file

@ -8,11 +8,7 @@ class CategoryTwitch {
final String id;
final String name;
const CategoryTwitch(
this.boxArtUrl,
this.id,
this.name,
);
const CategoryTwitch(this.boxArtUrl, this.id, this.name);
factory CategoryTwitch.fromJson(Map<String, dynamic> json) =>
_$CategoryTwitchFromJson(json);
@ -23,10 +19,7 @@ class CategoriesTwitch {
final List<CategoryTwitch> data;
final Map<String, String>? pagination;
const CategoriesTwitch(
this.data,
this.pagination,
);
const CategoriesTwitch(this.data, this.pagination);
factory CategoriesTwitch.fromJson(Map<String, dynamic> json) =>
_$CategoriesTwitchFromJson(json);

View file

@ -8,11 +8,19 @@ class Channel {
final String broadcasterId;
final String broadcasterLogin;
final String broadcasterName;
final String broadcasterLanguage;
final String title;
final String gameId;
final String gameName;
const Channel({
required this.broadcasterId,
required this.broadcasterLogin,
required this.broadcasterName,
required this.broadcasterLanguage,
required this.title,
required this.gameId,
required this.gameName,
});
factory Channel.fromJson(Map<String, dynamic> json) =>

View file

@ -10,7 +10,11 @@ Channel _$ChannelFromJson(Map<String, dynamic> json) => Channel(
broadcasterId: json['broadcaster_id'] as String,
broadcasterLogin: json['broadcaster_login'] as String,
broadcasterName: json['broadcaster_name'] as String,
);
broadcasterLanguage: json['broadcaster_language'] as String,
title: json['title'] as String,
gameId: json['game_id'] as String,
gameName: json['game_name'] as String,
);
ChannelQuery _$ChannelQueryFromJson(Map<String, dynamic> json) => ChannelQuery(
broadcasterLogin: json['broadcaster_login'] as String,
@ -18,4 +22,4 @@ ChannelQuery _$ChannelQueryFromJson(Map<String, dynamic> json) => ChannelQuery(
id: json['id'] as String,
isLive: json['is_live'] as bool,
startedAt: json['started_at'] as String,
);
);

View file

@ -7,10 +7,7 @@ class ChatUsers {
final int chatterCount;
final Chatters chatters;
ChatUsers(
this.chatterCount,
this.chatters,
);
ChatUsers(this.chatterCount, this.chatters);
factory ChatUsers.fromJson(Map<String, dynamic> json) =>
_$ChatUsersFromJson(json);

View file

@ -9,7 +9,7 @@ part of 'chatters.dart';
ChatUsers _$ChatUsersFromJson(Map<String, dynamic> json) => ChatUsers(
(json['chatter_count'] as num).toInt(),
Chatters.fromJson(json['chatters'] as Map<String, dynamic>),
);
);
Chatters _$ChattersFromJson(Map<String, dynamic> json) => Chatters(
(json['broadcaster'] as List<dynamic>).map((e) => e as String).toList(),
@ -19,4 +19,4 @@ Chatters _$ChattersFromJson(Map<String, dynamic> json) => Chatters(
(json['admins'] as List<dynamic>).map((e) => e as String).toList(),
(json['global_mods'] as List<dynamic>).map((e) => e as String).toList(),
(json['viewers'] as List<dynamic>).map((e) => e as String).toList(),
);
);

View file

@ -12,12 +12,7 @@ class EmoteTwitch {
final String? emoteType;
final String? ownerId;
const EmoteTwitch(
this.id,
this.name,
this.emoteType,
this.ownerId,
);
const EmoteTwitch(this.id, this.name, this.emoteType, this.ownerId);
factory EmoteTwitch.fromJson(Map<String, dynamic> json) =>
_$EmoteTwitchFromJson(json);
@ -29,10 +24,7 @@ class EmoteBTTV {
final String id;
final String code;
const EmoteBTTV(
this.id,
this.code,
);
const EmoteBTTV(this.id, this.code);
factory EmoteBTTV.fromJson(Map<String, dynamic> json) =>
_$EmoteBTTVFromJson(json);
@ -43,10 +35,7 @@ class EmoteBTTVChannel {
final List<EmoteBTTV> channelEmotes;
final List<EmoteBTTV> sharedEmotes;
const EmoteBTTVChannel(
this.channelEmotes,
this.sharedEmotes,
);
const EmoteBTTVChannel(this.channelEmotes, this.sharedEmotes);
factory EmoteBTTVChannel.fromJson(Map<String, dynamic> json) =>
_$EmoteBTTVChannelFromJson(json);
@ -59,11 +48,7 @@ class RoomFFZ {
final ImagesFFZ? vipBadge;
final ImagesFFZ? modUrls;
const RoomFFZ(
this.set,
this.vipBadge,
this.modUrls,
);
const RoomFFZ(this.set, this.vipBadge, this.modUrls);
factory RoomFFZ.fromJson(Map<String, dynamic> json) =>
_$RoomFFZFromJson(json);
@ -78,11 +63,7 @@ class ImagesFFZ {
@JsonKey(name: '4')
final String? url4x;
const ImagesFFZ(
this.url1x,
this.url2x,
this.url4x,
);
const ImagesFFZ(this.url1x, this.url2x, this.url4x);
factory ImagesFFZ.fromJson(Map<String, dynamic> json) =>
_$ImagesFFZFromJson(json);
@ -93,10 +74,7 @@ class OwnerFFZ {
final String displayName;
final String name;
const OwnerFFZ({
required this.displayName,
required this.name,
});
const OwnerFFZ({required this.displayName, required this.name});
factory OwnerFFZ.fromJson(Map<String, dynamic> json) =>
_$OwnerFFZFromJson(json);
@ -130,11 +108,7 @@ class Emote7TV {
final String name;
final Emote7TVData data;
const Emote7TV(
this.id,
this.name,
this.data,
);
const Emote7TV(this.id, this.name, this.data);
factory Emote7TV.fromJson(Map<String, dynamic> json) =>
_$Emote7TVFromJson(json);
@ -145,10 +119,7 @@ class Owner7TV {
final String username;
final String displayName;
const Owner7TV({
required this.username,
required this.displayName,
});
const Owner7TV({required this.username, required this.displayName});
factory Owner7TV.fromJson(Map<String, dynamic> json) =>
_$Owner7TVFromJson(json);
@ -162,13 +133,7 @@ class Emote7TVData {
final Owner7TV? owner;
final Emote7TVHost host;
const Emote7TVData(
this.id,
this.name,
this.flags,
this.owner,
this.host,
);
const Emote7TVData(this.id, this.name, this.flags, this.owner, this.host);
factory Emote7TVData.fromJson(Map<String, dynamic> json) =>
_$Emote7TVDataFromJson(json);
@ -179,10 +144,7 @@ class Emote7TVHost {
final String url;
final List<Emote7TVFile> files;
Emote7TVHost(
this.url,
this.files,
);
Emote7TVHost(this.url, this.files);
factory Emote7TVHost.fromJson(Map<String, dynamic> json) =>
_$Emote7TVHostFromJson(json);
@ -195,12 +157,7 @@ class Emote7TVFile {
final int height;
final String format;
Emote7TVFile(
this.name,
this.width,
this.height,
this.format,
);
Emote7TVFile(this.name, this.width, this.height, this.format);
factory Emote7TVFile.fromJson(Map<String, dynamic> json) =>
_$Emote7TVFileFromJson(json);
@ -254,7 +211,8 @@ class Emote {
zeroWidth: false,
width: emote.width,
height: emote.height,
url: emote.animated?.url4x ??
url:
emote.animated?.url4x ??
emote.animated?.url2x ??
emote.animated?.url1x ??
emote.urls.url4x ??

View file

@ -11,12 +11,10 @@ EmoteTwitch _$EmoteTwitchFromJson(Map<String, dynamic> json) => EmoteTwitch(
json['name'] as String,
json['emote_type'] as String?,
json['owner_id'] as String?,
);
);
EmoteBTTV _$EmoteBTTVFromJson(Map<String, dynamic> json) => EmoteBTTV(
json['id'] as String,
json['code'] as String,
);
EmoteBTTV _$EmoteBTTVFromJson(Map<String, dynamic> json) =>
EmoteBTTV(json['id'] as String, json['code'] as String);
EmoteBTTVChannel _$EmoteBTTVChannelFromJson(Map<String, dynamic> json) =>
EmoteBTTVChannel(
@ -36,18 +34,15 @@ RoomFFZ _$RoomFFZFromJson(Map<String, dynamic> json) => RoomFFZ(
json['mod_urls'] == null
? null
: ImagesFFZ.fromJson(json['mod_urls'] as Map<String, dynamic>),
);
);
ImagesFFZ _$ImagesFFZFromJson(Map<String, dynamic> json) => ImagesFFZ(
json['1'] as String,
json['2'] as String?,
json['4'] as String?,
);
ImagesFFZ _$ImagesFFZFromJson(Map<String, dynamic> json) =>
ImagesFFZ(json['1'] as String, json['2'] as String?, json['4'] as String?);
OwnerFFZ _$OwnerFFZFromJson(Map<String, dynamic> json) => OwnerFFZ(
displayName: json['display_name'] as String,
name: json['name'] as String,
);
);
EmoteFFZ _$EmoteFFZFromJson(Map<String, dynamic> json) => EmoteFFZ(
json['name'] as String,
@ -58,18 +53,18 @@ EmoteFFZ _$EmoteFFZFromJson(Map<String, dynamic> json) => EmoteFFZ(
json['animated'] == null
? null
: ImagesFFZ.fromJson(json['animated'] as Map<String, dynamic>),
);
);
Emote7TV _$Emote7TVFromJson(Map<String, dynamic> json) => Emote7TV(
json['id'] as String,
json['name'] as String,
Emote7TVData.fromJson(json['data'] as Map<String, dynamic>),
);
);
Owner7TV _$Owner7TVFromJson(Map<String, dynamic> json) => Owner7TV(
username: json['username'] as String,
displayName: json['display_name'] as String,
);
);
Emote7TVData _$Emote7TVDataFromJson(Map<String, dynamic> json) => Emote7TVData(
json['id'] as String,
@ -79,21 +74,21 @@ Emote7TVData _$Emote7TVDataFromJson(Map<String, dynamic> json) => Emote7TVData(
? null
: Owner7TV.fromJson(json['owner'] as Map<String, dynamic>),
Emote7TVHost.fromJson(json['host'] as Map<String, dynamic>),
);
);
Emote7TVHost _$Emote7TVHostFromJson(Map<String, dynamic> json) => Emote7TVHost(
json['url'] as String,
(json['files'] as List<dynamic>)
.map((e) => Emote7TVFile.fromJson(e as Map<String, dynamic>))
.toList(),
);
);
Emote7TVFile _$Emote7TVFileFromJson(Map<String, dynamic> json) => Emote7TVFile(
json['name'] as String,
(json['width'] as num).toInt(),
(json['height'] as num).toInt(),
json['format'] as String,
);
);
Emote _$EmoteFromJson(Map<String, dynamic> json) => Emote(
name: json['name'] as String,
@ -106,7 +101,7 @@ Emote _$EmoteFromJson(Map<String, dynamic> json) => Emote(
ownerDisplayName: json['ownerDisplayName'] as String?,
ownerUsername: json['ownerUsername'] as String?,
ownerId: json['ownerId'] as String?,
);
);
Map<String, dynamic> _$EmoteToJson(Emote instance) => <String, dynamic>{
'name': instance.name,
@ -119,7 +114,7 @@ Map<String, dynamic> _$EmoteToJson(Emote instance) => <String, dynamic>{
'ownerDisplayName': instance.ownerDisplayName,
'ownerUsername': instance.ownerUsername,
'ownerId': instance.ownerId,
};
};
const _$EmoteTypeEnumMap = {
EmoteType.twitchBits: 'twitchBits',

View file

@ -10,11 +10,7 @@ class SevenTVEvent {
final int? t;
final SevenTVEventData d;
const SevenTVEvent({
required this.op,
this.t,
required this.d,
});
const SevenTVEvent({required this.op, this.t, required this.d});
factory SevenTVEvent.fromJson(Map<String, dynamic> json) =>
_$SevenTVEventFromJson(json);
@ -29,11 +25,7 @@ class SevenTVEventData {
@JsonKey(includeToJson: false)
final SevenTVEventEmoteSetBody? body;
const SevenTVEventData({
required this.type,
this.condition,
this.body,
});
const SevenTVEventData({required this.type, this.condition, this.body});
factory SevenTVEventData.fromJson(Map<String, dynamic> json) =>
_$SevenTVEventDataFromJson(json);
@ -46,10 +38,7 @@ class SevenTVEventUpdatedEmote {
final Emote7TV? value;
final Emote7TV? oldValue;
const SevenTVEventUpdatedEmote({
this.value,
this.oldValue,
});
const SevenTVEventUpdatedEmote({this.value, this.oldValue});
factory SevenTVEventUpdatedEmote.fromJson(Map<String, dynamic> json) =>
_$SevenTVEventUpdatedEmoteFromJson(json);

View file

@ -10,14 +10,10 @@ SevenTVEvent _$SevenTVEventFromJson(Map<String, dynamic> json) => SevenTVEvent(
op: (json['op'] as num).toInt(),
t: (json['t'] as num?)?.toInt(),
d: SevenTVEventData.fromJson(json['d'] as Map<String, dynamic>),
);
);
Map<String, dynamic> _$SevenTVEventToJson(SevenTVEvent instance) =>
<String, dynamic>{
'op': instance.op,
if (instance.t case final value?) 't': value,
'd': instance.d,
};
<String, dynamic>{'op': instance.op, 't': ?instance.t, 'd': instance.d};
SevenTVEventData _$SevenTVEventDataFromJson(Map<String, dynamic> json) =>
SevenTVEventData(
@ -28,36 +24,32 @@ SevenTVEventData _$SevenTVEventDataFromJson(Map<String, dynamic> json) =>
body: json['body'] == null
? null
: SevenTVEventEmoteSetBody.fromJson(
json['body'] as Map<String, dynamic>),
json['body'] as Map<String, dynamic>,
),
);
Map<String, dynamic> _$SevenTVEventDataToJson(SevenTVEventData instance) =>
<String, dynamic>{
'type': instance.type,
'condition': instance.condition,
};
<String, dynamic>{'type': instance.type, 'condition': instance.condition};
SevenTVEventUpdatedEmote _$SevenTVEventUpdatedEmoteFromJson(
Map<String, dynamic> json) =>
SevenTVEventUpdatedEmote(
Map<String, dynamic> json,
) => SevenTVEventUpdatedEmote(
value: json['value'] == null
? null
: Emote7TV.fromJson(json['value'] as Map<String, dynamic>),
oldValue: json['old_value'] == null
? null
: Emote7TV.fromJson(json['old_value'] as Map<String, dynamic>),
);
);
SevenTVEventEmoteSetBody _$SevenTVEventEmoteSetBodyFromJson(
Map<String, dynamic> json) =>
SevenTVEventEmoteSetBody(
Map<String, dynamic> json,
) => SevenTVEventEmoteSetBody(
actor: Owner7TV.fromJson(json['actor'] as Map<String, dynamic>),
pushed: (json['pushed'] as List<dynamic>?)
?.map((e) =>
SevenTVEventUpdatedEmote.fromJson(e as Map<String, dynamic>))
?.map((e) => SevenTVEventUpdatedEmote.fromJson(e as Map<String, dynamic>))
.toList(),
pulled: (json['pulled'] as List<dynamic>?)
?.map((e) =>
SevenTVEventUpdatedEmote.fromJson(e as Map<String, dynamic>))
?.map((e) => SevenTVEventUpdatedEmote.fromJson(e as Map<String, dynamic>))
.toList(),
);
);

View file

@ -0,0 +1,39 @@
import 'package:json_annotation/json_annotation.dart';
part 'followed_channel.g.dart';
// Object for followed Twitch channels.
@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
class FollowedChannel {
final String broadcasterId;
final String broadcasterLogin;
final String broadcasterName;
final DateTime followedAt;
const FollowedChannel({
required this.broadcasterId,
required this.broadcasterLogin,
required this.broadcasterName,
required this.followedAt,
});
factory FollowedChannel.fromJson(Map<String, dynamic> json) =>
_$FollowedChannelFromJson(json);
}
// Response object for followed channels API call.
@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
class FollowedChannels {
final List<FollowedChannel> data;
final Map<String, String> pagination;
final int total;
const FollowedChannels({
required this.data,
required this.pagination,
required this.total,
});
factory FollowedChannels.fromJson(Map<String, dynamic> json) =>
_$FollowedChannelsFromJson(json);
}

View file

@ -0,0 +1,24 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'followed_channel.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
FollowedChannel _$FollowedChannelFromJson(Map<String, dynamic> json) =>
FollowedChannel(
broadcasterId: json['broadcaster_id'] as String,
broadcasterLogin: json['broadcaster_login'] as String,
broadcasterName: json['broadcaster_name'] as String,
followedAt: DateTime.parse(json['followed_at'] as String),
);
FollowedChannels _$FollowedChannelsFromJson(Map<String, dynamic> json) =>
FollowedChannels(
data: (json['data'] as List<dynamic>)
.map((e) => FollowedChannel.fromJson(e as Map<String, dynamic>))
.toList(),
pagination: Map<String, String>.from(json['pagination'] as Map),
total: (json['total'] as num).toInt(),
);

View file

@ -11,12 +11,23 @@ import 'package:frosty/models/emotes.dart';
import 'package:frosty/models/user.dart';
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:frosty/widgets/photo_view.dart';
import 'package:frosty/utils.dart' as utils;
import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:frosty/widgets/frosty_cached_network_image.dart';
import 'package:frosty/widgets/frosty_photo_view_dialog.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
// Constants for IRC message rendering
const String _ircActionPrefix = '\x01';
const String _ircActionSuffix = '\x01';
const int _ircActionPrefixLength = 8;
const String _invalidUnicodeChar = '\u{E0000}';
const String _twitchEmoteBaseUrl = 'https://static-cdn.jtvnw.net/emoticons/v2';
const String _twitchIrcServer = 'tmi.twitch.tv';
const String _emoteModeSuffix = '/default/dark/3.0';
const double _emojiSizeOffset = 5.0;
/// The object representation of a Twitch IRC message.
class IRCMessage {
final String raw;
@ -28,6 +39,8 @@ class IRCMessage {
List<String>? split;
bool? action;
bool? mention;
final VoidCallback? actionCallback;
final String? actionLabel;
IRCMessage({
required this.raw,
@ -39,8 +52,15 @@ class IRCMessage {
this.split,
this.action,
this.mention,
this.actionCallback,
this.actionLabel,
});
/// The channel this message actually originated from. In Twitch shared
/// chat, `source-room-id` names the participant channel a message was
/// sent in; otherwise it's just this connection's own room.
String? get sourceChannelId => tags['source-room-id'] ?? tags['room-id'];
/// Applies the given CLEARCHAT message to the provided messages and buffer.
static void clearChat({
required List<IRCMessage> messages,
@ -112,43 +132,25 @@ class IRCMessage {
}
}
/// Returns an [InlineSpan] list that corresponds to the badges, username, words, and emotes of the given [IRCMessage].
List<InlineSpan> generateSpan(
BuildContext context, {
/// Adds timestamp to the span if enabled
void _addTimestamp(
List<InlineSpan> span,
TextStyle? style,
required ChatAssetsStore assetsStore,
required double badgeScale,
required double emoteScale,
required bool launchExternal,
void Function()? onTapName,
void Function(String)? onTapPingedUser,
bool showMessage = true,
Map<String, UserTwitch>? channelIdToUserTwitch,
TimestampType timestamp = TimestampType.disabled,
}) {
final isLightTheme = Theme.of(context).brightness == Brightness.light;
TimestampType timestamp,
) {
if (timestamp == TimestampType.disabled) return;
final emoteToObject = assetsStore.emoteToObject;
final twitchBadgeToObject = assetsStore.twitchBadgesToObject;
final ffzUserToBadges = assetsStore.userToFFZBadges;
final sevenTVUserToBadges = assetsStore.userTo7TVBadges;
final bttvUserToBadge = assetsStore.userToBTTVBadges;
final ffzRoomInfo = assetsStore.ffzRoomInfo;
final badgeSize = defaultBadgeSize * badgeScale;
final emoteSize = defaultEmoteSize * emoteScale;
// The span list that will be used to render the chat message
final span = <InlineSpan>[];
if (timestamp != TimestampType.disabled) {
final time = tags['tmi-sent-ts'] ??
DateTime.now().millisecondsSinceEpoch.toString();
final time =
tags['tmi-sent-ts'] ?? DateTime.now().millisecondsSinceEpoch.toString();
final parsedTime = DateTime.fromMillisecondsSinceEpoch(int.parse(time));
if (timestamp == TimestampType.twentyFour) {
final timeText = timestamp == TimestampType.twentyFour
? '${DateFormat.Hm().format(parsedTime)} '
: '${DateFormat('h:mm').format(parsedTime)} ';
span.add(
TextSpan(
text: '${DateFormat.Hm().format(parsedTime)} ',
text: timeText,
style: style?.copyWith(
color: style.color?.withValues(alpha: 0.5),
fontFeatures: [FontFeature.tabularFigures()],
@ -157,19 +159,14 @@ class IRCMessage {
);
}
if (timestamp == TimestampType.twelve) {
span.add(
TextSpan(
text: '${DateFormat('h:mm').format(parsedTime)} ',
style: style?.copyWith(
color: style.color?.withValues(alpha: 0.5),
fontFeatures: [FontFeature.tabularFigures()],
),
),
);
}
}
/// Adds historical and channel source badges to the span
void _addHistoricalAndChannelBadges(
BuildContext context,
List<InlineSpan> span,
double badgeSize,
Map<String, UserTwitch>? channelIdToUserTwitch,
String? currentChannelId,
) {
final isHistorical = tags['historical'] == '1';
if (isHistorical) {
span.add(
@ -179,25 +176,34 @@ class IRCMessage {
message: 'Historical message',
preferBelow: false,
triggerMode: TooltipTriggerMode.tap,
child: Icon(Icons.history_rounded, size: badgeSize),
child: Icon(
Icons.history_rounded,
size: badgeSize,
color:
Theme.of(context).iconTheme.color?.withValues(alpha: 0.5) ??
Theme.of(context).colorScheme.onSurfaceVariant.withValues(
alpha: 0.5,
),
),
),
),
);
span.add(const TextSpan(text: ' '));
}
final sourceChannelId = tags['source-room-id'] ?? tags['room-id'];
final sourceChannelUser = channelIdToUserTwitch != null
? channelIdToUserTwitch[sourceChannelId]
: null;
final sourceChannelUser = channelIdToUserTwitch?[sourceChannelId];
if (sourceChannelUser != null) {
final isCurrentChannel = sourceChannelId == currentChannelId;
span.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Tooltip(
triggerMode: TooltipTriggerMode.tap,
preferBelow: false,
message: 'Sent from ${sourceChannelUser.displayName}',
message: isCurrentChannel
? 'Sent from ${sourceChannelUser.displayName} (current channel)'
: 'Sent from ${sourceChannelUser.displayName}',
child: CachedNetworkImage(
cacheManager: CustomCacheManager.instance,
imageUrl: sourceChannelUser.profileImageUrl,
@ -206,8 +212,22 @@ class IRCMessage {
height: badgeSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
image:
DecorationImage(image: imageProvider, fit: BoxFit.cover),
border: isCurrentChannel
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 1.5,
)
: null,
),
child: Container(
margin: isCurrentChannel ? const EdgeInsets.all(1.5) : null,
decoration: BoxDecoration(
shape: BoxShape.circle,
image: DecorationImage(
image: imageProvider,
fit: BoxFit.cover,
),
),
),
),
placeholder: (context, url) => Container(
@ -215,7 +235,12 @@ class IRCMessage {
height: badgeSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.grey,
border: isCurrentChannel
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 1.5,
)
: null,
),
),
),
@ -224,32 +249,47 @@ class IRCMessage {
);
span.add(const TextSpan(text: ' '));
}
}
// Indicator to skip adding the bot badges later when adding the rest of FFZ badges.
/// Adds all user badges (Twitch, FFZ, BTTV, 7TV) to the span
bool _addUserBadges(
BuildContext context,
List<InlineSpan> span,
ChatAssetsStore assetsStore,
double badgeSize,
bool launchExternal,
bool isHistorical,
) {
var skipBot = false;
final twitchBadgeToObject = assetsStore.twitchBadgesToObject;
final ffzUserToBadges = assetsStore.userToFFZBadges;
final sevenTVUserToBadges = assetsStore.userTo7TVBadges;
final bttvUserToBadge = assetsStore.userToBTTVBadges;
final ffzRoomInfo = assetsStore.ffzRoomInfo;
final ffzUserBadges = ffzUserToBadges[tags['user-id']];
final twitchBadges = tags['badges']?.split(',');
// Pasrse and add the Twitch badges to the span if they exist.
// Add Twitch badges
if (twitchBadges != null) {
for (final badge in twitchBadges) {
final badgeInfo = twitchBadgeToObject[badge];
if (badgeInfo != null) {
var badgeUrl = badgeInfo.url;
// Add custom FFZ mod badge if it exists.
// Handle custom FFZ mod badge
if (badgeInfo.name == 'Moderator' &&
(ffzUserBadges != null || ffzRoomInfo?.modUrls != null)) {
// Check if mod is bot.
final botBadge = ffzUserBadges
?.firstWhereOrNull((element) => element.name == 'Bot');
final botBadge = ffzUserBadges?.firstWhereOrNull(
(element) => element.name == 'Bot',
);
// Check if user has bot badge or room has custom FFZ mod badges
if (botBadge != null) {
badgeUrl = botBadge.url;
skipBot = true;
} else if (ffzRoomInfo?.modUrls != null) {
badgeUrl = ffzRoomInfo!.modUrls?.url4x ??
badgeUrl =
ffzRoomInfo!.modUrls?.url4x ??
ffzRoomInfo.modUrls?.url2x ??
ffzRoomInfo.modUrls!.url1x;
}
@ -273,9 +313,10 @@ class IRCMessage {
continue;
}
// Add custom FFZ vip badge if it exists
// Handle custom FFZ VIP badge
if (badgeInfo.name == 'VIP' && ffzRoomInfo?.vipBadge != null) {
badgeUrl = ffzRoomInfo!.vipBadge?.url4x ??
badgeUrl =
ffzRoomInfo!.vipBadge?.url4x ??
ffzRoomInfo.vipBadge?.url2x ??
ffzRoomInfo.vipBadge!.url1x;
}
@ -299,7 +340,7 @@ class IRCMessage {
}
}
// Add FFZ badges to span
// Add FFZ badges
if (ffzUserBadges != null) {
for (final badge in ffzUserBadges) {
final color = Color(int.parse(badge.color!.replaceFirst('#', '0xFF')));
@ -334,7 +375,7 @@ class IRCMessage {
}
}
// Add BTTV badges to span
// Add BTTV badges
final userBTTVBadge = bttvUserToBadge[tags['user-id']];
if (userBTTVBadge != null) {
span.add(
@ -349,7 +390,7 @@ class IRCMessage {
span.add(const TextSpan(text: ' '));
}
// Add 7TV badges to end of badges span
// Add 7TV badges
final user7TVBadges = sevenTVUserToBadges[tags['user-id']];
if (user7TVBadges != null) {
for (final badge in user7TVBadges) {
@ -365,115 +406,200 @@ class IRCMessage {
}
}
return skipBot;
}
/// Adds the username to the span with proper color and styling
void _addUsername(
List<InlineSpan> span,
BuildContext context,
void Function()? onTapName,
) {
var color = Color(
int.parse((tags['color'] ?? '#868686').replaceFirst('#', '0xFF')),
);
final hsl = HSLColor.fromColor(color);
if (isLightTheme == true) {
if (hsl.lightness >= 0.5) {
color = hsl
.withLightness(hsl.lightness + ((0 - hsl.lightness) * 0.5))
.toColor();
}
} else {
if (hsl.lightness <= 0.5) {
color = hsl
.withLightness(hsl.lightness + ((1 - hsl.lightness) * 0.5))
.toColor();
}
}
// Adjust color for theme contrast using the common utility
color = utils.adjustChatNameColor(context, color);
// Add the display name (username) to the span and apply the onLongPressName callback.
final displayName = tags['display-name']!;
span.add(
TextSpan(
text: user != null ? getReadableName(displayName, user!) : displayName,
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
),
text: user != null
? utils.getReadableName(displayName, user!)
: displayName,
style: TextStyle(color: color, fontWeight: FontWeight.bold),
recognizer: TapGestureRecognizer()..onTap = onTapName,
),
);
// Add the colon separator between the username and their message to the span.
// Add the colon separator between the username and their message
if (action == false) span.add(const TextSpan(text: ':'));
}
// Italicize the text if it was called with an IRC Action (e.g., "/me").
final textStyle =
action == true ? const TextStyle(fontStyle: FontStyle.italic) : style;
/// Adds message content with emotes, emojis and text processing
void _addMessageContent(
BuildContext context,
List<InlineSpan> span,
Map<String, Emote> emoteToObject,
double emoteSize,
double emoteScale,
bool showMessage,
bool launchExternal,
TextStyle? textStyle,
void Function(String)? onTapPingedUser,
void Function()? onTapDeletedMessage, {
Color? linkColor,
}) {
if (!showMessage) {
span.add(const TextSpan(text: ' <message deleted>'));
} else {
// Check if the message is a reply. If it is, remove the reply username (first word) from the message.
span.add(
TextSpan(
text: ' <message deleted>',
style: onTapDeletedMessage != null
? textStyle?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500,
)
: null,
recognizer: onTapDeletedMessage != null
? (TapGestureRecognizer()..onTap = onTapDeletedMessage)
: null,
),
);
return;
}
// Check if the message is a reply. If it is, remove the reply username from the message.
final words = tags.containsKey('reply-parent-display-name')
? split?.sublist(1)
: split;
// Add the message and any emotes to the span.
if (words != null) {
// Keep a local span which will be reversed and added to the final span.
final localSpan = <InlineSpan>[];
// Iterate through the words in reverse-order.
// Index starts at the last word in the message.
var index = words.length - 1;
// Terminate after the first word in the message reached.
while (index != -1) {
// Check if current word is a valid emote.
final word = words[index];
final emote = emoteToObject[word] ?? localEmotes?[word];
if (emote != null) {
// If the emote is zero-width and not the first word, process the stack.
if (emote.zeroWidth && index != 0) {
index = _processZeroWidthEmoteStack(
context,
localSpan,
words,
index,
emote,
emoteToObject,
emoteSize,
emoteScale,
textStyle,
launchExternal,
linkColor: linkColor,
);
} else {
localSpan.add(
_createEmoteSpan(
context,
emote: emote,
height: emote.height != null
? emote.height! * emoteScale
: emoteSize,
width: emote.width != null ? emote.width! * emoteScale : null,
launchExternal: launchExternal,
),
);
localSpan.add(const TextSpan(text: ' '));
index--;
}
} else {
if (regexEmoji.hasMatch(word)) {
localSpan.add(
_createEmojiSpan(
emoji: word,
style: textStyle?.copyWith(
fontSize: emoteSize - _emojiSizeOffset,
),
),
);
} else {
localSpan.add(
_createTextSpan(
text: word,
style: textStyle,
linkColor: linkColor,
launchExternal: launchExternal,
onTapPingedUser: onTapPingedUser,
),
);
}
localSpan.add(const TextSpan(text: ' '));
index--;
}
}
// Add the local span reversed to the final span.
span.addAll(localSpan.reversed);
}
}
/// Handles zero-width emote stacking logic and returns updated index
int _processZeroWidthEmoteStack(
BuildContext context,
List<InlineSpan> localSpan,
List<String> words,
int startIndex,
Emote startEmote,
Map<String, Emote> emoteToObject,
double emoteSize,
double emoteScale,
TextStyle? textStyle,
bool launchExternal, {
Color? linkColor,
}) {
final emoteStack = <Emote>[];
var index = startIndex;
// Handle stacking consecutive zero-width emotes.
Emote? nextEmote = emote;
Emote? nextEmote = startEmote;
while (nextEmote != null && nextEmote.zeroWidth && index != 0) {
emoteStack.add(nextEmote);
index--;
nextEmote =
emoteToObject[words[index]] ?? localEmotes?[words[index]];
nextEmote = emoteToObject[words[index]] ?? localEmotes?[words[index]];
}
// If there's one more emote thats NOT zero-width, add it to the base of the stack.
// If there's one more emote that's NOT zero-width, add it to the base of the stack.
if (nextEmote != null) emoteStack.add(nextEmote);
// Check if the next word is an emoji to be added to the stack.
final nextWordIsEmoji = regexEmoji.hasMatch(words[index]);
// Create the stack of emotes with the base emoji if there is one.
// Will be used as the children of the Stack widget.
final children = [
if (nextWordIsEmoji)
Text(
words[index],
style: textStyle?.copyWith(fontSize: emoteSize - 5),
style: textStyle?.copyWith(fontSize: emoteSize - _emojiSizeOffset),
),
...emoteStack.reversed.map(
(emote) => FrostyCachedNetworkImage(
imageUrl: emote.url,
height: emote.height != null
? emote.height! * emoteScale
: emoteSize,
width: emote.width != null
? emote.width! * emoteScale
: emoteSize,
height: emote.height != null ? emote.height! * emoteScale : emoteSize,
width: emote.width != null ? emote.width! * emoteScale : emoteSize,
useFade: false,
),
),
];
// Create the message for the tooltip
final message = emoteStack.reversed.map(
(emote) => emote.name,
);
final message = emoteStack.reversed.map((emote) => emote.name);
final emoji = words[index];
localSpan.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
@ -484,15 +610,10 @@ class IRCMessage {
alignment: AlignmentDirectional.center,
children: [
if (nextWordIsEmoji)
Text(
emoji,
style: textStyle?.copyWith(fontSize: 40),
),
Text(emoji, style: textStyle?.copyWith(fontSize: 40)),
...emoteStack.reversed.map(
(emote) => FrostyCachedNetworkImage(
imageUrl: emote.url,
width: 56,
),
(emote) =>
FrostyCachedNetworkImage(imageUrl: emote.url, width: 56),
),
],
),
@ -513,59 +634,101 @@ class IRCMessage {
),
);
// If the next word is neither an emote or emoji, add it as a text span.
// If the next word is neither an emote nor emoji, add it as a text span.
if (nextEmote == null && !nextWordIsEmoji) {
localSpan.add(const TextSpan(text: ' '));
localSpan.add(
_createTextSpan(
text: words[index],
style: textStyle,
launchExternal: launchExternal,
onTapPingedUser: onTapPingedUser,
),
);
}
} else {
localSpan.add(
_createEmoteSpan(
context,
emote: emote,
height: emote.height != null
? emote.height! * emoteScale
: emoteSize,
width: emote.width != null ? emote.width! * emoteScale : null,
linkColor: linkColor,
launchExternal: launchExternal,
),
);
}
} else {
if (regexEmoji.hasMatch(word)) {
localSpan.add(
_createEmojiSpan(
emoji: word,
style: textStyle?.copyWith(fontSize: emoteSize - 5),
),
);
} else {
localSpan.add(
_createTextSpan(
text: word,
style: textStyle,
launchExternal: launchExternal,
onTapPingedUser: onTapPingedUser,
),
);
}
}
// Add space after the zero-width emote stack (like all other elements)
localSpan.add(const TextSpan(text: ' '));
index--;
return index - 1;
}
// Add the the local span reversed to the final span.
span.addAll(localSpan.reversed);
}
}
/// Returns an [InlineSpan] list that corresponds to the badges, username, words, and emotes of the given [IRCMessage].
List<InlineSpan> generateSpan(
BuildContext context, {
TextStyle? style,
required ChatAssetsStore assetsStore,
required double badgeScale,
required double emoteScale,
required bool launchExternal,
void Function()? onTapName,
void Function(String)? onTapPingedUser,
void Function()? onTapDeletedMessage,
bool showMessage = true,
Map<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>[];
// When the user has timestamps disabled globally, fall back to the device's
// locale preference for historical messages (opt-in via setting) and for
// forced contexts like previews.
final isHistorical = tags['historical'] == '1';
final effectiveTimestamp =
(timestamp == TimestampType.disabled &&
((isHistorical && showHistoricalTimestamps) || forceTimestamp))
? (MediaQuery.alwaysUse24HourFormatOf(context)
? TimestampType.twentyFour
: TimestampType.twelve)
: timestamp;
_addTimestamp(span, style, effectiveTimestamp);
_addHistoricalAndChannelBadges(
context,
span,
badgeSize,
channelIdToUserTwitch,
currentChannelId,
);
_addUserBadges(
context,
span,
assetsStore,
badgeSize,
launchExternal,
isHistorical,
);
_addUsername(span, context, onTapName);
// Italicize the text if it was called with an IRC Action (e.g., "/me").
final textStyle = action == true
? const TextStyle(fontStyle: FontStyle.italic)
: style;
_addMessageContent(
context,
span,
emoteToObject,
emoteSize,
emoteScale,
showMessage,
launchExternal,
textStyle,
onTapPingedUser,
onTapDeletedMessage,
linkColor: linkColor,
);
return span;
}
@ -576,10 +739,7 @@ class IRCMessage {
}) {
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Text(
emoji,
style: style,
),
child: Text(emoji, style: style),
);
}
@ -600,11 +760,7 @@ class IRCMessage {
),
);
} else if (isSvg == true) {
return SvgPicture.network(
badge.url,
height: size,
width: size,
);
return SvgPicture.network(badge.url, height: size, width: size);
} else {
return FrostyCachedNetworkImage(
imageUrl: badge.url,
@ -680,6 +836,7 @@ class IRCMessage {
required String text,
required bool launchExternal,
TextStyle? style,
Color? linkColor,
Function(String)? onTapPingedUser,
}) {
if (text.startsWith('@')) {
@ -697,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(
@ -721,10 +878,7 @@ class IRCMessage {
}) {
_showAssetDetailsBottomSheet(
context,
leading: FrostyCachedNetworkImage(
imageUrl: emote.url,
width: 56,
),
leading: FrostyCachedNetworkImage(imageUrl: emote.url, width: 56),
url: emote.url,
title: emote.realName != null
? '${emote.name} (${emote.realName})'
@ -735,7 +889,7 @@ class IRCMessage {
Text(emote.type.toString()),
if (emote.ownerDisplayName != null && emote.ownerUsername != null)
Text(
'by ${getReadableName(emote.ownerDisplayName!, emote.ownerUsername!)}',
'by ${utils.getReadableName(emote.ownerDisplayName!, emote.ownerUsername!)}',
),
],
),
@ -752,7 +906,7 @@ class IRCMessage {
required bool launchExternal,
bool showCopyName = true,
}) {
showModalBottomSheet(
showModalBottomSheetWithProperFocus(
context: context,
builder: (context) => ListView(
shrinkWrap: true,
@ -768,12 +922,11 @@ class IRCMessage {
),
title: Text(
title,
style: const TextStyle(
fontWeight: FontWeight.w600,
),
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: subtitle,
),
const Divider(indent: 16, endIndent: 16),
if (showCopyName)
ListTile(
leading: const Icon(Icons.copy_rounded),
@ -825,21 +978,31 @@ class IRCMessage {
// Obtain the index to break off the tags.
final tagAndIrcMessageDivider = whole.indexOf(' ');
// Get the tags substring and escape characters.
// IRC messages escape spaces with \s.
final tags =
whole.substring(1, tagAndIrcMessageDivider).replaceAll('\\s', ' ');
// Without a separating space there are no tags to slice and no message body
// to parse — bail out rather than throw on the substring/index calls below.
if (tagAndIrcMessageDivider == -1) {
return IRCMessage(raw: whole, command: Command.none, tags: const {});
}
// Next, parse and map the tags.
// Parse the IRCv3 tags into a map. The tags section is everything between
// the leading '@' and the first space. We split on the *raw* string: a
// literal ';' is always a separator because escaped semicolons inside a
// value are encoded as `\:` (unescaped per-value below).
final mappedTags = <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.
@ -853,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] == 'tmi.twitch.tv'
// 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']
: splitMessage[0].substring(0, splitMessage[0].indexOf('!'));
: prefix.substring(0, bangIndex);
}
// If there is an associated message, set it.
String? message;
@ -883,12 +1064,14 @@ class IRCMessage {
for (final emoteIdAndPosition in emotes) {
final indexBetweenIdAndPositions = emoteIdAndPosition.indexOf(':');
final emoteId =
emoteIdAndPosition.substring(0, indexBetweenIdAndPositions);
final emoteId = emoteIdAndPosition.substring(
0,
indexBetweenIdAndPositions,
);
// Parse the range in order to extract the associated word.
// If there are more than one indices, use the first one.
// Else, use the one provided indices.
// Parse the range to extract the associated word.
// If there is more than one index, use the first.
// Otherwise, use the provided index.
final String range;
if (emoteIdAndPosition.contains(',')) {
range = emoteIdAndPosition.substring(
@ -910,8 +1093,7 @@ class IRCMessage {
localEmotes[emoteWord] = Emote(
name: emoteWord,
zeroWidth: false,
url:
'https://static-cdn.jtvnw.net/emoticons/v2/$emoteId/default/dark/3.0',
url: '$_twitchEmoteBaseUrl/$emoteId$_emoteModeSuffix',
type: EmoteType.twitchSub,
);
}
@ -922,9 +1104,10 @@ class IRCMessage {
List<String>? split;
if (message != null) {
// Check if IRC actions like "/me" were called.
if (message.startsWith('\x01') && message.endsWith('\x01')) {
if (message.startsWith(_ircActionPrefix) &&
message.endsWith(_ircActionSuffix)) {
action = true;
message = message.substring(8, message.length - 1);
message = message.substring(_ircActionPrefixLength, message.length - 1);
}
// Check if the message mentions the logged-in user
@ -938,7 +1121,7 @@ class IRCMessage {
// Also remove any "INVALID/UNDEFINED" Unicode characters.
// Rendering this character on iOS shows a question mark inside a square.
// This character is used by some clients to bypass restrictions on repeating message.
.map((word) => word.replaceAll('\u{E0000}', '').trim())
.map((word) => word.replaceAll(_invalidUnicodeChar, '').trim())
.where((element) => element != '')
.join(' ');
@ -1016,11 +1199,63 @@ class IRCMessage {
);
}
factory IRCMessage.createNotice({required String message}) => 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,
actionLabel: actionLabel,
);
}
@ -1069,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

@ -17,6 +17,5 @@ SharedChatSession _$SharedChatSessionFromJson(Map<String, dynamic> json) =>
updatedAt: json['updated_at'] as String,
);
Participant _$ParticipantFromJson(Map<String, dynamic> json) => Participant(
broadcasterId: json['broadcaster_id'] as String,
);
Participant _$ParticipantFromJson(Map<String, dynamic> json) =>
Participant(broadcasterId: json['broadcaster_id'] as String);

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);
@ -36,11 +46,22 @@ class StreamsTwitch {
final List<StreamTwitch> data;
final Map<String, String> pagination;
const StreamsTwitch(
this.data,
this.pagination,
);
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,7 +16,9 @@ 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) =>
StreamsTwitch(

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

@ -10,12 +10,7 @@ class UserTwitch {
final String displayName;
final String profileImageUrl;
const UserTwitch(
this.id,
this.login,
this.displayName,
this.profileImageUrl,
);
const UserTwitch(this.id, this.login, this.displayName, this.profileImageUrl);
factory UserTwitch.fromJson(Map<String, dynamic> json) =>
_$UserTwitchFromJson(json);
@ -27,11 +22,7 @@ class UserBlockedTwitch {
final String userLogin;
final String displayName;
const UserBlockedTwitch(
this.userId,
this.userLogin,
this.displayName,
);
const UserBlockedTwitch(this.userId, this.userLogin, this.displayName);
factory UserBlockedTwitch.fromJson(Map<String, dynamic> json) =>
_$UserBlockedTwitchFromJson(json);

View file

@ -11,7 +11,7 @@ UserTwitch _$UserTwitchFromJson(Map<String, dynamic> json) => UserTwitch(
json['login'] as String,
json['display_name'] as String,
json['profile_image_url'] as String,
);
);
UserBlockedTwitch _$UserBlockedTwitchFromJson(Map<String, dynamic> json) =>
UserBlockedTwitch(

View file

@ -3,30 +3,30 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/apis/bttv_api.dart';
import 'package:frosty/apis/ffz_api.dart';
import 'package:frosty/apis/seventv_api.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/screens/channel/chat/chat.dart';
import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/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_bar.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/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/theme.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/widgets/app_bar.dart';
import 'package:frosty/widgets/notification.dart';
import 'package:provider/provider.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/blurred_container.dart';
import 'package:frosty/widgets/draggable_divider.dart';
import 'package:frosty/widgets/frosty_notification.dart';
import 'package:simple_pip_mode/actions/pip_actions_layout.dart';
import 'package:simple_pip_mode/pip_widget.dart';
/// Creates a widget that shows the video stream (if live) and chat of the given user.
class VideoChat extends StatefulWidget {
static const routeName = 'VideoChat';
final String userId;
final String userName;
final String userLogin;
@ -42,59 +42,293 @@ class VideoChat extends StatefulWidget {
State<VideoChat> createState() => _VideoChatState();
}
class _VideoChatState extends State<VideoChat> {
class _VideoChatState extends State<VideoChat>
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
final _videoKey = GlobalKey();
final _chatKey = GlobalKey();
late final ChatStore _chatStore = ChatStore(
twitchApi: context.read<TwitchApi>(),
channelName: widget.userLogin,
channelId: widget.userId,
displayName: widget.userName,
auth: context.read<AuthStore>(),
settings: context.read<SettingsStore>(),
chatDetailsStore: ChatDetailsStore(
twitchApi: context.read<TwitchApi>(),
channelName: widget.userLogin,
),
assetsStore: ChatAssetsStore(
twitchApi: context.read<TwitchApi>(),
ffzApi: context.read<FFZApi>(),
bttvApi: context.read<BTTVApi>(),
sevenTVApi: context.read<SevenTVApi>(),
),
// PiP drag state — ValueNotifier avoids full widget rebuilds during drag.
final _pipDragDistance = ValueNotifier<double>(0);
bool _isPipDragging = false;
bool _isInPipTriggerZone = false;
// Divider drag state for synchronizing animation
bool _isDividerDragging = false;
// Essential constants for good UX balance
static const double _pipTriggerDistance = 80;
static const double _pipMaxDragDistance = 150;
// Animation controller for smooth spring-back
late AnimationController _animationController;
late Animation<double> _springBackAnimation;
bool? _isImmersiveSystemUiEnabled;
late final ChatTabsStore _chatTabsStore = ChatTabsStore(
twitchApi: context.twitchApi,
bttvApi: context.bttvApi,
ffzApi: context.ffzApi,
sevenTVApi: context.sevenTVApi,
authStore: context.authStore,
settingsStore: context.settingsStore,
globalAssetsStore: context.globalAssetsStore,
primaryChannelId: widget.userId,
primaryChannelLogin: widget.userLogin,
primaryDisplayName: widget.userName,
);
late final VideoStore _videoStore = VideoStore(
late final VideoPlayerInterface _videoStore =
context.settingsStore.useNativePlayer
? NativeVideoStore(
userLogin: widget.userLogin,
twitchApi: context.read<TwitchApi>(),
authStore: context.read<AuthStore>(),
settingsStore: context.read<SettingsStore>(),
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
Widget build(BuildContext context) {
final orientation = MediaQuery.of(context).orientation;
void initState() {
super.initState();
final settingsStore = _chatStore.settings;
// Initialize animation controller for smooth drag interactions
_animationController = AnimationController(
duration: const Duration(milliseconds: 250),
vsync: this,
);
final appBar = FrostyAppBar(
title: Text(
getReadableName(_chatStore.displayName, _chatStore.channelName),
// Spring-back animation with smooth easing
_springBackAnimation = Tween<double>(begin: 0, end: 0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
);
// Drive the ValueNotifier from the spring-back animation so the
// AnimatedBuilder rebuilds without a full setState.
_animationController.addListener(() {
_pipDragDistance.value = _springBackAnimation.value;
});
// Register as observer for app lifecycle events
WidgetsBinding.instance.addObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.resumed) {
_videoStore.handleAppResume();
}
}
void _handlePipDragStart(DragStartDetails details) {
if (_videoStore.isInPipMode || _videoStore.paused || _videoStore.loading) {
return;
}
_animationController.stop();
_pipDragDistance.value = 0;
_isInPipTriggerZone = false;
setState(() {
_isPipDragging = true;
});
}
void _handlePipDragUpdate(DragUpdateDetails details) {
if (!_isPipDragging ||
_videoStore.isInPipMode ||
_videoStore.paused ||
_videoStore.loading) {
return;
}
// Update the ValueNotifier directly — only the ListenableBuilder rebuilds,
// not the entire channel scaffold.
_pipDragDistance.value =
(_pipDragDistance.value + details.delta.dy).clamp(0, _pipMaxDragDistance);
final wasInTriggerZone = _isInPipTriggerZone;
_isInPipTriggerZone = _pipDragDistance.value >= _pipTriggerDistance;
if (!wasInTriggerZone && _isInPipTriggerZone) {
HapticFeedback.mediumImpact();
} else if (wasInTriggerZone && !_isInPipTriggerZone) {
HapticFeedback.lightImpact();
}
}
void _handlePipDragEnd(DragEndDetails details) {
if (!_isPipDragging ||
_videoStore.isInPipMode ||
_videoStore.paused ||
_videoStore.loading) {
return;
}
final velocity = details.velocity.pixelsPerSecond.dy;
final shouldTriggerPip =
_pipDragDistance.value >= _pipTriggerDistance || velocity > 600;
if (shouldTriggerPip) {
HapticFeedback.mediumImpact();
_videoStore.requestPictureInPicture();
_resetDragState();
} else {
_animateSpringBack();
}
}
void _handlePipDragCancel() {
if (!_isPipDragging) return;
_animateSpringBack();
}
void _resetDragState() {
_pipDragDistance.value = 0;
_isInPipTriggerZone = false;
setState(() {
_isPipDragging = false;
});
}
void _animateSpringBack() {
_springBackAnimation =
Tween<double>(begin: _pipDragDistance.value, end: 0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
);
_animationController.reset();
_animationController.forward().then((_) {
_resetDragState();
});
}
void _scheduleSystemUiModeSync({required bool immersive}) {
if (_isImmersiveSystemUiEnabled == immersive) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _isImmersiveSystemUiEnabled == immersive) return;
_isImmersiveSystemUiEnabled = immersive;
if (immersive) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
} else {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
}
});
}
/// Wraps a video widget with PiP swipe-down gesture handling.
///
/// Provides visual feedback (translate + scale), haptic feedback,
/// and an instructional overlay during the drag gesture.
Widget _buildPipGestureWrapper({required Widget child, double? aspectRatio}) {
final wrappedChild = aspectRatio != null
? AspectRatio(aspectRatio: aspectRatio, child: child)
: child;
// Listen to the ValueNotifier (drag) and AnimationController (spring-back)
// so only this subtree rebuilds during the gesture — not the full scaffold.
return ListenableBuilder(
listenable: _pipDragDistance,
child: wrappedChild,
builder: (context, animatedChild) {
final currentDragDistance = _pipDragDistance.value;
final scaleFactor =
1.0 - (currentDragDistance / _pipMaxDragDistance * 0.1);
return Transform.translate(
offset: Offset(0, currentDragDistance),
child: Transform.scale(
scale: scaleFactor.clamp(0.9, 1.0),
child: Stack(
children: [
GestureDetector(
onPanStart: _handlePipDragStart,
onPanUpdate: _handlePipDragUpdate,
onPanEnd: _handlePipDragEnd,
onPanCancel: _handlePipDragCancel,
child: animatedChild,
),
if (!_videoStore.isInPipMode)
Positioned.fill(
child: IgnorePointer(
ignoring:
!(_isPipDragging && currentDragDistance > 0),
child: AnimatedOpacity(
opacity:
(_isPipDragging && currentDragDistance > 0)
? 1.0
: 0.0,
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
child: Container(
color: Colors.black.withValues(alpha: 0.4),
child: const Center(
child: Text(
'Swipe down to enter picture-in-picture',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
),
),
),
),
],
),
),
);
},
);
}
/// Convenience getter for the currently active chat store.
ChatStore get _chatStore => _chatTabsStore.activeChatStore;
@override
Widget build(BuildContext context) {
final settingsStore = _chatTabsStore.settingsStore;
final player = GestureDetector(
onLongPress: _videoStore.handleToggleOverlay,
child: Video(
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,
videoStore: _videoStore,
nativeVideoStore: _videoStore,
)
: Video(
key: _videoKey,
videoStore: _videoStore as VideoStore,
),
);
final overlay = GestureDetector(
onLongPress: _videoStore.handleToggleOverlay,
onDoubleTap: orientation == Orientation.landscape
onDoubleTap: context.isLandscape
? () => settingsStore.fullScreen = !settingsStore.fullScreen
: null,
onTap: () {
@ -102,7 +336,7 @@ class _VideoChatState extends State<VideoChat> {
_chatStore.assetsStore.showEmoteMenu = false;
} else {
if (_chatStore.textFieldFocusNode.hasFocus) {
_chatStore.textFieldFocusNode.unfocus();
_chatStore.unfocusInput();
} else {
_videoStore.handleVideoTap();
}
@ -116,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;
}
@ -124,11 +360,8 @@ class _VideoChatState extends State<VideoChat> {
opacity: _videoStore.overlayVisible ? 1.0 : 0.0,
curve: Curves.ease,
duration: const Duration(milliseconds: 200),
child: Container(
decoration: BoxDecoration(
color: Colors.black
.withValues(alpha: settingsStore.overlayOpacity),
),
child: ColoredBox(
color: Colors.transparent,
child: IgnorePointer(
ignoring: !_videoStore.overlayVisible,
child: videoOverlay,
@ -139,106 +372,152 @@ class _VideoChatState extends State<VideoChat> {
),
);
final video = Observer(
builder: (context) {
if (!_videoStore.settingsStore.showOverlay) return player;
return Stack(
final video = Stack(
children: [
player,
overlay,
Positioned.fill(
child: Observer(
builder: (_) => AnimatedOpacity(
opacity: _videoStore.loading ? 1.0 : 0.0,
// Appear instantly to cover native player placeholders during
// recovery; fade out smoothly when playback resumes.
duration: _videoStore.loading
? Duration.zero
: const Duration(milliseconds: 300),
curve: Curves.easeOut,
// TickerMode stops the spinner's repeating animation while
// hidden — opacity 0 skips painting but would otherwise keep
// scheduling frames for the whole video session.
child: IgnorePointer(
child: TickerMode(
enabled: _videoStore.loading,
child: const ColoredBox(
color: Colors.black,
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: Colors.white38,
strokeWidth: 2,
),
),
),
),
),
),
),
),
),
Observer(
builder: (_) => _videoStore.settingsStore.showOverlay
? overlay
: const SizedBox.shrink(),
),
],
);
},
);
final chat = Observer(
builder: (context) {
final videoBarVisible = _videoStore.streamInfo != null &&
_chatStore.settings.showVideo &&
(_videoStore.paused || _videoStore.overlayVisible);
final bool chatOnly = !settingsStore.showVideo;
return Stack(
children: [
Chat(
ChatTabs(
key: _chatKey,
chatStore: _chatStore,
chatTabsStore: _chatTabsStore,
// In chat-only mode, account for the blurred AppBar height
listPadding: chatOnly
? EdgeInsets.only(top: context.safePaddingTop)
: null,
),
if (orientation == Orientation.portrait)
AnimatedOpacity(
opacity: videoBarVisible ? 1 : 0,
curve: Curves.ease,
duration: const Duration(milliseconds: 200),
child: IgnorePointer(
ignoring: !videoBarVisible,
child: ColoredBox(
color: Theme.of(context).scaffoldBackgroundColor,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_videoStore.streamInfo != null)
VideoBar(
streamInfo: _videoStore.streamInfo!,
tappableCategory: false,
),
const Divider(),
],
),
),
),
),
AnimatedSwitcher(
Observer(
builder: (_) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: _chatStore.notification != null
? Align(
alignment: _chatStore.settings.chatNotificationsOnBottom
? Alignment.bottomCenter
: Alignment.topCenter,
alignment: Alignment.topCenter,
child: Padding(
padding: EdgeInsets.only(
top: chatOnly ? context.safePaddingTop : 0,
),
child: FrostyNotification(
message: _chatStore.notification!,
showPasteButton:
_chatStore.notification!.contains('copied'),
onButtonPressed: () async {
// Paste clipboard text into the text controller.
final data =
await Clipboard.getData(Clipboard.kTextPlain);
if (data != null) {
_chatStore.textController.text = data.text!;
}
_chatStore.updateNotification('');
},
onDismissed: _chatStore.clearNotification,
),
),
)
: null,
),
),
],
);
},
);
final videoChat = Scaffold(
final videoChat = Observer(
builder: (context) {
_scheduleSystemUiModeSync(
immersive:
context.isLandscape && !settingsStore.landscapeForceVerticalChat,
);
// Build a blurred AppBar when in chat-only mode (no video)
PreferredSizeWidget? chatOnlyBlurredAppBar;
if (!settingsStore.showVideo) {
final streamInfo = _videoStore.streamInfo;
chatOnlyBlurredAppBar = AppBar(
centerTitle: false,
elevation: 0,
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
systemOverlayStyle: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness:
context.theme.brightness == Brightness.dark
? Brightness.light
: Brightness.dark,
),
title: StreamInfoBar(
streamInfo: streamInfo,
offlineChannelInfo: _videoStore.offlineChannelInfo,
displayName: _chatStore.displayName,
isCompact: true,
isOffline: streamInfo == null,
isInSharedChatMode: _chatStore.isInSharedChatMode,
showTextShadows: false,
),
flexibleSpace: BlurredContainer(
gradientDirection: GradientDirection.up,
child: const SizedBox.expand(),
),
);
}
return Scaffold(
extendBody: true,
extendBodyBehindAppBar: true,
appBar: chatOnlyBlurredAppBar,
body: Observer(
builder: (context) {
if (orientation == Orientation.landscape &&
if (context.isLandscape &&
!settingsStore.landscapeForceVerticalChat) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
final landscapeChat = AnimatedContainer(
curve: Curves.ease,
duration: const Duration(milliseconds: 200),
duration: _isDividerDragging
? Duration.zero
: const Duration(milliseconds: 200),
width: _chatStore.expandChat
? MediaQuery.of(context).size.width / 2
: MediaQuery.of(context).size.width *
_chatStore.settings.chatWidth,
color: _chatStore.settings.fullScreen
? context.screenWidth / 2
: context.screenWidth * settingsStore.chatWidth,
color: settingsStore.fullScreen
? Colors.black.withValues(
alpha: _chatStore.settings.fullScreenChatOverlayOpacity,
alpha: settingsStore.fullScreenChatOverlayOpacity,
)
: Theme.of(context).scaffoldBackgroundColor,
: context.scaffoldColor,
child: chat,
);
@ -250,12 +529,8 @@ class _VideoChatState extends State<VideoChat> {
colorSchemeSeed: Color(settingsStore.accentColor),
).dark,
child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(
color: context
.watch<FrostyThemes>()
.dark
.colorScheme
.onSurface,
style: context.defaultTextStyle.copyWith(
color: context.frostyColors.overlayOnSurface,
),
child: landscapeChat,
),
@ -265,29 +540,55 @@ class _VideoChatState extends State<VideoChat> {
return ColoredBox(
color: settingsStore.showVideo
? Colors.black
: Theme.of(context).scaffoldBackgroundColor,
child: SafeArea(
bottom: false,
left: (settingsStore.landscapeCutout ==
LandscapeCutoutType.both ||
settingsStore.landscapeCutout ==
LandscapeCutoutType.left)
? false
: true,
right: (settingsStore.landscapeCutout ==
LandscapeCutoutType.both ||
settingsStore.landscapeCutout ==
LandscapeCutoutType.right)
? false
: true,
: context.scaffoldColor,
child: settingsStore.showVideo
? settingsStore.fullScreen
? Stack(
children: [
player,
_buildPipGestureWrapper(child: player),
if (settingsStore.showOverlay)
LayoutBuilder(
builder: (context, constraints) {
final totalWidth = constraints.maxWidth;
final chatWidth = _chatStore.expandChat
? 0.5
: settingsStore.chatWidth;
final draggableDivider = Observer(
builder: (_) => DraggableDivider(
currentWidth: chatWidth,
maxWidth: 0.6,
isResizableOnLeft: settingsStore
.landscapeChatLeftSide,
showHandle:
_videoStore.overlayVisible &&
settingsStore
.fullScreenChatOverlay,
onDragStart: () {
setState(() {
_isDividerDragging = true;
});
},
onDrag: (newWidth) {
if (!_chatStore.expandChat) {
settingsStore.chatWidth =
newWidth;
}
},
onDragEnd: () {
setState(() {
_isDividerDragging = false;
});
},
),
);
return Stack(
children: [
Row(
children: settingsStore.landscapeChatLeftSide
children:
settingsStore
.landscapeChatLeftSide
? [
overlayChat,
Expanded(child: overlay),
@ -297,54 +598,198 @@ class _VideoChatState extends State<VideoChat> {
overlayChat,
],
),
if (settingsStore
.fullScreenChatOverlay)
Positioned(
top: 0,
bottom: 0,
left:
settingsStore
.landscapeChatLeftSide
? (totalWidth * chatWidth) -
12
: null,
right:
!settingsStore
.landscapeChatLeftSide
? (totalWidth * chatWidth) -
12
: null,
child: draggableDivider,
),
],
);
},
),
],
)
: Row(
children: settingsStore.landscapeChatLeftSide
: SafeArea(
bottom: false,
left:
settingsStore.landscapeCutout !=
LandscapeCutoutType.left &&
settingsStore.landscapeCutout !=
LandscapeCutoutType.both,
right:
settingsStore.landscapeCutout !=
LandscapeCutoutType.right &&
settingsStore.landscapeCutout !=
LandscapeCutoutType.both,
child: LayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.maxWidth;
final chatWidth = _chatStore.expandChat
? 0.5
: settingsStore.chatWidth;
// Create the landscape chat container with proper styling
final chatContainer = AnimatedContainer(
curve: Curves.ease,
duration: _isDividerDragging
? Duration.zero
: const Duration(milliseconds: 200),
width: availableWidth * chatWidth,
color: settingsStore.fullScreen
? Colors.black.withValues(
alpha: settingsStore
.fullScreenChatOverlayOpacity,
)
: context.scaffoldColor,
child: chat,
);
final draggableDivider = Observer(
builder: (_) => DraggableDivider(
currentWidth: chatWidth,
maxWidth: 0.6,
isResizableOnLeft:
settingsStore.landscapeChatLeftSide,
showHandle: _videoStore.overlayVisible,
onDragStart: () {
setState(() {
_isDividerDragging = true;
});
},
onDrag: (newWidth) {
if (!_chatStore.expandChat) {
settingsStore.chatWidth = newWidth;
}
},
onDragEnd: () {
setState(() {
_isDividerDragging = false;
});
},
),
);
return Stack(
children: [
Row(
children:
settingsStore
.landscapeChatLeftSide
? [
landscapeChat,
const VerticalDivider(),
Expanded(child: video),
chatContainer,
Expanded(
child:
_buildPipGestureWrapper(
child: video,
),
),
]
: [
Expanded(child: video),
const VerticalDivider(),
landscapeChat,
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,
),
],
);
},
),
)
: Column(
children: [appBar, Expanded(child: chat)],
),
),
: SafeArea(child: chat),
);
}
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
return SafeArea(
child: Column(
// Keep chat-only scrolling under the blurred app bar, but
// ensure video does not bleed into the system status bar.
top: settingsStore.showVideo,
bottom: false,
child: Stack(
children: [
if (!settingsStore.showVideo)
appBar
else ...[
AspectRatio(aspectRatio: 16 / 9, child: video),
const Divider(),
Column(
children: [
if (settingsStore.showVideo) ...[
AspectRatio(
aspectRatio: 16 / 9,
child: SizedBox(),
),
ChannelInfoHeader(
videoStore: _videoStore,
authStore: context.authStore,
userId: widget.userId,
displayName: widget.userName,
isInSharedChatMode:
_chatStore.isInSharedChatMode,
),
],
Expanded(child: chat),
],
),
if (settingsStore.showVideo)
Positioned(
top: 0,
left: 0,
right: 0,
child: _buildPipGestureWrapper(
child: video,
aspectRatio: 16 / 9,
),
),
],
),
);
},
),
);
},
);
// If on Android, use PiPSwitcher to enable PiP functionality.
if (Platform.isAndroid) {
// Both players need this — the WebView store used to be skipped by a
// NativeVideoPlayerInterface type check, so it never learned it was in
// PiP on Android.
void notifyPip(bool isInPip) => _videoStore.handleAndroidPipChanged(isInPip);
return PipWidget(
pipLayout: PipActionsLayout.mediaOnlyPause,
onPipAction: (_) => _videoStore.handlePausePlay(),
onPipEntered: () => notifyPip(true),
onPipExited: () => notifyPip(false),
pipChild: player,
child: videoChat,
);
@ -355,10 +800,16 @@ class _VideoChatState extends State<VideoChat> {
@override
void dispose() {
_chatStore.dispose();
// Remove observer for app lifecycle events
WidgetsBinding.instance.removeObserver(this);
_chatTabsStore.dispose();
_videoStore.dispose();
_animationController.dispose();
_pipDragDistance.dispose();
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,

View file

@ -1,18 +1,42 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart';
import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_bottom_bar.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
import 'package:frosty/widgets/page_view.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/frosty_page_view.dart';
import 'package:frosty/widgets/frosty_scrollbar.dart';
/// ~2x default to keep richer message widgets (emotes, badges, replies) built
/// ahead of fast scroll-back without holding the entire history.
const _chatCacheExtent = ScrollCacheExtent.pixels(500.0);
class Chat extends StatelessWidget {
final ChatStore chatStore;
final EdgeInsetsGeometry? listPadding;
const Chat({super.key, required this.chatStore});
/// Callback to add a new chat tab.
/// Passes this to ChatBottomBar for the ChatDetails menu.
final VoidCallback onAddChat;
/// When set, the widget operates in merged mode: messages come from all tabs
/// and the active tab determines the send target.
final ChatTabsStore? chatTabsStore;
const Chat({
super.key,
required this.chatStore,
this.listPadding,
required this.onAddChat,
this.chatTabsStore,
});
bool get _isMerged => chatTabsStore != null;
@override
Widget build(BuildContext context) {
@ -21,42 +45,100 @@ class Chat extends StatelessWidget {
return Column(
children: [
Expanded(
child: GestureDetector(
child: Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
// Wrap only the message list with GestureDetector
// so taps on ChatBottomBar don't trigger unfocus
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (chatStore.assetsStore.showEmoteMenu) {
chatStore.assetsStore.showEmoteMenu = false;
} else if (chatStore.textFieldFocusNode.hasFocus) {
chatStore.textFieldFocusNode.unfocus();
chatStore.unfocusInput();
}
},
child: Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
MediaQuery(
child: MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler:
TextScaler.linear(chatStore.settings.messageScale),
textScaler: chatStore.settings.messageScale.textScaler,
),
child: DefaultTextStyle(
style: DefaultTextStyle.of(context)
.style
.copyWith(fontSize: chatStore.settings.fontSize),
child: Scrollbar(
controller: chatStore.scrollController,
style: context.defaultTextStyle.copyWith(
fontSize: chatStore.settings.fontSize,
),
child: Builder(
builder: (context) {
// Don't add bottom padding in horizontal landscape
// (immersive mode with home indicator on side).
// landscapeForceVerticalChat uses portrait layout
// with normal system UI, so still needs padding.
final isHorizontalLandscape =
context.isLandscape &&
!chatStore.settings.landscapeForceVerticalChat;
final bottomPadding =
chatStore.assetsStore.showEmoteMenu ||
isHorizontalLandscape
? 0.0
: MediaQuery.of(context).padding.bottom;
final scrollController = _isMerged
? chatTabsStore!.mergedScrollController
: chatStore.scrollController;
// Freeze the message list while a finger rests on a
// message so a long-press target can't scroll out
// from under it (which would dispose its recognizer
// mid-press). The store resumes flowing as soon as
// the pointer moves like a scroll, so scrolling is
// never interrupted — only a stationary hold pauses.
void onDown(PointerDownEvent e) => _isMerged
? chatTabsStore!.onMessageListPointerDown(
e.pointer,
e.position,
)
: chatStore.onMessageListPointerDown(
e.pointer,
e.position,
);
void onMove(PointerMoveEvent e) => _isMerged
? chatTabsStore!.onMessageListPointerMove(
e.pointer,
e.position,
)
: chatStore.onMessageListPointerMove(
e.pointer,
e.position,
);
void onUp(int pointer) => _isMerged
? chatTabsStore!.onMessageListPointerUp(pointer)
: chatStore.onMessageListPointerUp(pointer);
return Listener(
onPointerDown: onDown,
onPointerMove: onMove,
onPointerUp: (e) => onUp(e.pointer),
onPointerCancel: (e) => onUp(e.pointer),
child: FrostyScrollbar(
controller: scrollController,
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top,
bottom:
chatStore.bottomBarHeight + bottomPadding,
),
child: Observer(
builder: (context) {
return ListView.builder(
reverse: true,
padding: EdgeInsets.zero,
addAutomaticKeepAlives: false,
controller: chatStore.scrollController,
itemCount: chatStore.renderMessages.length,
itemBuilder: (context, index) => ChatMessage(
ircMessage: chatStore.renderMessages[
chatStore.renderMessages.length -
1 -
index],
chatStore: chatStore,
return _isMerged
? _buildMergedList(
scrollController,
bottomPadding,
)
: _buildNormalList(
scrollController,
bottomPadding,
);
},
),
),
);
},
@ -64,89 +146,211 @@ class Chat extends StatelessWidget {
),
),
),
Padding(
padding: const EdgeInsets.all(4),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: ChatBottomBar(
chatStore: chatStore,
onAddChat: onAddChat,
channelDisplayName: _isMerged
? getReadableName(
chatTabsStore!.activeTab.displayName,
chatTabsStore!.activeTab.channelLogin,
)
: null,
),
),
_buildResumeScrollButton(),
],
),
),
_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: (_) => AnimatedSwitcher(
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: chatStore.autoScroll
child: isAutoScrolling
? null
: ElevatedButton.icon(
onPressed: chatStore.resumeScroll,
icon:
const Icon(Icons.arrow_downward_rounded),
onPressed: onResume,
icon: const Icon(Icons.arrow_downward_rounded),
label: Text(
chatStore.messageBuffer.isNotEmpty
? '${chatStore.messageBuffer.length} new ${chatStore.messageBuffer.length == 1 ? 'message' : 'messages'}'
bufferCount > 0
? '$bufferCount new ${bufferCount == 1 ? 'message' : 'messages'}'
: 'Resume scroll',
style: const TextStyle(
fontFeatures: [
FontFeature.tabularFigures(),
],
fontFeatures: [FontFeature.tabularFigures()],
),
),
),
),
),
),
],
),
),
),
if (chatStore.settings.showBottomBar)
ChatBottomBar(chatStore: chatStore),
PopScope(
canPop: Platform.isIOS,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
// If pressing the back button on Android while the emote menu
// is open, close it instead of going back to the streams list.
if (chatStore.assetsStore.showEmoteMenu) {
chatStore.assetsStore.showEmoteMenu = false;
} else {
Navigator.of(context).pop();
}
);
},
child: AnimatedContainer(
),
);
},
);
}
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
? MediaQuery.of(context).size.height /
(MediaQuery.of(context).orientation ==
Orientation.portrait
? 3
: 2)
: 0,
height: chatStore.assetsStore.showEmoteMenu ? menuHeight : 0,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 100),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: chatStore.assetsStore.showEmoteMenu
? Column(
? 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.showTwitchEmotes) 'Twitch',
if (chatStore.settings.show7TVEmotes) '7TV',
if (chatStore.settings.showBTTVEmotes) 'BTTV',
if (chatStore.settings.showFFZEmotes) 'FFZ',
],
children: [
RecentEmotesPanel(
chatStore: chatStore,
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,
.assetsStore
.userEmoteSectionToEmotes,
),
...[
if (chatStore.settings.show7TVEmotes)
@ -165,14 +369,11 @@ class Chat extends StatelessWidget {
),
),
],
),
),
)
: null,
),
),
),
],
);
},
);
}
}

View file

@ -1,12 +1,20 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/main.dart';
import 'package:frosty/models/irc.dart';
import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
import 'package:frosty/screens/channel/chat/details/chat_modes.dart';
import 'package:frosty/screens/channel/chat/details/chat_users_list.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/settings/settings.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:frosty/widgets/animated_scroll_border.dart';
import 'package:frosty/widgets/frosty_scrollbar.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:intl/intl.dart';
@ -15,11 +23,16 @@ class ChatDetails extends StatefulWidget {
final ChatStore chatStore;
final String userLogin;
/// Callback to add a new chat tab.
/// Shows an "Add chat" option in the menu.
final VoidCallback onAddChat;
const ChatDetails({
super.key,
required this.chatDetailsStore,
required this.chatStore,
required this.userLogin,
required this.onAddChat,
});
@override
@ -29,6 +42,9 @@ class ChatDetails extends StatefulWidget {
class _ChatDetailsState extends State<ChatDetails> {
late final _scrollController = ScrollController();
bool _isRefreshingAssets = false;
bool _showRefreshSuccess = false;
String formatDuration(Duration duration) {
if (duration.inMinutes < 60) {
return '${duration.inMinutes} ${Intl.plural(duration.inMinutes, one: 'minute', other: 'minutes')}';
@ -43,11 +59,11 @@ class _ChatDetailsState extends State<ChatDetails> {
final seconds = duration.inSeconds.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m left';
return '${hours}h ${minutes}m';
} else if (minutes > 0) {
return '${minutes}m ${seconds}s left';
return '${minutes}m ${seconds}s';
} else {
return '${seconds}s left';
return '${seconds}s';
}
}
@ -71,7 +87,7 @@ class _ChatDetailsState extends State<ChatDetails> {
Duration(hours: 12),
];
return showModalBottomSheet(
return showModalBottomSheetWithProperFocus(
context: context,
builder: (context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -91,12 +107,26 @@ class _ChatDetailsState extends State<ChatDetails> {
builder: (context) {
return ListTile(
leading: const Icon(Icons.close_rounded),
title: Text(
'Turn off (${formatTimeLeft(widget.chatStore.timeRemaining)})',
style: const TextStyle(
title: Text.rich(
TextSpan(
text: 'Cancel ',
children: [
TextSpan(
text: formatTimeLeft(
widget.chatStore.timeRemaining,
),
style: TextStyle(
color: Theme.of(context)
.textTheme
.bodyMedium
?.color
?.withValues(alpha: 0.6),
fontFeatures: [FontFeature.tabularFigures()],
),
),
],
),
),
onTap: () {
widget.chatStore.cancelSleepTimer();
@ -128,49 +158,207 @@ class _ChatDetailsState extends State<ChatDetails> {
);
}
Future<void> _showClearDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => AlertDialog.adaptive(
title: const Text('Clear recent emotes'),
content:
const Text('Are you sure you want to clear your recent emotes?'),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
widget.chatStore.assetsStore.recentEmotes.clear();
/// Converts a hex color string to a color name if it matches one of our predefined chat colors.
/// Returns null if no match is found.
String? _hexToColorName(String hexColor) {
// Remove # if present
final cleanHex = hexColor.startsWith('#')
? hexColor.substring(1)
: hexColor;
Navigator.pop(context);
},
child: const Text('Yes'),
// Convert to uppercase for comparison
final upperHex = cleanHex.toUpperCase();
// Find matching color name
for (final entry in chatColorValues.entries) {
final colorValue = entry.value.toARGB32().toRadixString(16).toUpperCase();
// Remove alpha channel (first 2 characters) for comparison
if (colorValue.length >= 6) {
final colorHex = colorValue.substring(2);
if (colorHex == upperHex) {
return entry.key;
}
}
}
return null;
}
Future<void> _showChatColorPicker(BuildContext context) async {
final selectedColor = await _getCurrentUserColor();
if (!context.mounted) return;
showModalBottomSheetWithProperFocus(
context: context,
builder: (context) => SizedBox(
height: MediaQuery.of(context).size.height * 0.5,
child: _ChatColorPickerModal(
initialColor: selectedColor,
scrollController: _scrollController,
onColorSelected: _handleColorUpdate,
chatStore: widget.chatStore,
),
],
),
);
}
Future<String?> _getCurrentUserColor() async {
try {
final currentColorHex = await widget.chatStore.twitchApi.getUserChatColor(
userId: widget.chatStore.auth.user.details!.id,
);
if (currentColorHex.isNotEmpty) {
return _hexToColorName(currentColorHex);
}
} catch (e) {
// Ignore errors when fetching current color
}
return null;
}
Future<void> _handleColorUpdate(BuildContext context, String color) async {
final success = await widget.chatStore.twitchApi.updateUserChatColor(
userId: widget.chatStore.auth.user.details!.id,
color: color,
);
if (!context.mounted) return;
Navigator.of(context).pop();
final message = success
? 'Chat color updated successfully!'
: 'Failed to update chat color. Please try again.';
_showMessage(context, message);
}
void _showMessage(BuildContext context, String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
bool _hasActiveModes() {
final roomState = widget.chatDetailsStore.roomState;
return roomState.subMode != '0' ||
roomState.followersOnly != '-1' ||
roomState.emoteOnly != '0' ||
roomState.slowMode != '0' ||
roomState.r9k != '0';
}
Widget _buildRefreshTrailingWidget() {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: _isRefreshingAssets
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
)
: _showRefreshSuccess
? Icon(
Icons.check_rounded,
color: Theme.of(context).colorScheme.primary,
key: const ValueKey('success'),
)
: const SizedBox.shrink(),
);
}
Future<void> _handleRefreshAssets() async {
setState(() => _isRefreshingAssets = true);
try {
await widget.chatStore.getAssets();
if (mounted) {
HapticFeedback.lightImpact();
setState(() {
_isRefreshingAssets = false;
_showRefreshSuccess = true;
});
// Hide success after 2 seconds
Timer(const Duration(seconds: 2), () {
if (mounted) {
setState(() => _showRefreshSuccess = false);
}
});
}
} catch (e) {
if (mounted) {
setState(() => _isRefreshingAssets = false);
}
}
}
@override
Widget build(BuildContext context) {
return Observer(
builder: (context) {
final hasActiveModes = _hasActiveModes();
final children = [
const SectionHeader(
'Chat modes',
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
isFirst: true,
if (hasActiveModes)
ListTile(
title: SizedBox(
height: 48,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
Observer(
builder: (context) => ChatModes(
roomState: widget.chatDetailsStore.roomState,
),
),
],
),
),
),
Observer(
builder: (context) {
final showVideo = widget.chatStore.settings.showVideo;
final label = showVideo ? 'Chat only' : 'Show video';
return ListTile(
leading: Icon(
showVideo ? Icons.chat_rounded : Icons.tv_rounded,
),
title: Text(label),
onTap: () {
widget.chatStore.settings.showVideo = !showVideo;
Navigator.of(context).pop();
},
);
},
),
ListTile(
title: ChatModes(roomState: widget.chatDetailsStore.roomState),
),
const SectionHeader(
'More',
leading: const Icon(Icons.sync_rounded),
title: const Text('Refresh emotes and badges'),
trailing: _buildRefreshTrailingWidget(),
enabled: !_isRefreshingAssets,
onTap: _isRefreshingAssets ? null : _handleRefreshAssets,
),
ListTile(
leading: const Icon(Icons.people_outline),
leading: const Icon(Icons.wifi_off_rounded),
title: const Text('Reconnect'),
onTap: () {
Navigator.of(context).pop();
widget.chatStore.updateNotification('Reconnecting to chat...');
widget.chatStore.connectToChat();
},
),
if (widget.chatStore.auth.isLoggedIn)
ListTile(
leading: const Icon(Icons.palette_rounded),
title: const Text('Username color'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showChatColorPicker(context),
),
ListTile(
leading: const Icon(Icons.people_rounded),
title: const Text('Chatters'),
onTap: () => showModalBottomSheet(
trailing: const Icon(Icons.chevron_right),
onTap: () => showModalBottomSheetWithProperFocus(
isScrollControlled: true,
context: context,
builder: (context) => GestureDetector(
@ -183,61 +371,52 @@ class _ChatDetailsState extends State<ChatDetails> {
),
),
),
ListTile(
leading: const Icon(Icons.add_comment_rounded),
title: const Text('Add chat'),
trailing: const Icon(Icons.chevron_right),
onTap: widget.onAddChat,
),
Observer(
builder: (context) {
final hasTimer = widget.chatStore.timeRemaining.inSeconds > 0;
final label = hasTimer
? formatTimeLeft(widget.chatStore.timeRemaining)
: 'Sleep timer';
return ListTile(
leading: const Icon(Icons.timer_outlined),
leading: Icon(
hasTimer ? Icons.timer_rounded : Icons.timer_rounded,
color: hasTimer
? Theme.of(context).colorScheme.primary
: null,
),
title: Text(
'Sleep timer ${widget.chatStore.timeRemaining.inSeconds > 0 ? 'on (${formatTimeLeft(widget.chatStore.timeRemaining)})' : ''}',
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
label,
style: hasTimer
? TextStyle(
fontFeatures: const [FontFeature.tabularFigures()],
color: Theme.of(context).colorScheme.primary,
)
: null,
),
trailing: hasTimer
? Icon(
Icons.chevron_right,
color: Theme.of(context).colorScheme.primary,
)
: const Icon(Icons.chevron_right),
onTap: () => _showSleepTimer(context),
);
},
),
ListTile(
leading: const Icon(Icons.delete_outline_rounded),
title: const Text('Clear recent emotes'),
onTap: () => _showClearDialog(context),
),
ListTile(
leading: const Icon(Icons.refresh_rounded),
title: const Text('Reconnect to chat'),
onTap: () {
widget.chatStore.updateNotification('Reconnecting to chat...');
widget.chatStore.connectToChat();
},
),
ListTile(
leading: const Icon(Icons.refresh_rounded),
title: const Text('Refresh badges and emotes'),
onTap: () async {
await widget.chatStore.getAssets();
widget.chatStore.updateNotification('Badges and emotes refreshed');
},
),
ListTile(
leading: const Icon(Icons.chat_outlined),
title: Observer(
builder: (context) {
return Text(
'${widget.chatStore.settings.showVideo ? 'Enter' : 'Exit'} chat-only mode',
);
},
),
onTap: () => widget.chatStore.settings.showVideo =
!widget.chatStore.settings.showVideo,
),
ListTile(
leading: const Icon(Icons.settings_outlined),
leading: const Icon(Icons.settings_rounded),
title: const Text('Settings'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: Settings.routeName),
builder: (context) =>
Settings(settingsStore: widget.chatStore.settings),
),
@ -245,10 +424,8 @@ class _ChatDetailsState extends State<ChatDetails> {
),
];
return ListView(
shrinkWrap: true,
primary: false,
children: children,
return ListView(shrinkWrap: true, primary: false, children: children);
},
);
}
@ -258,3 +435,187 @@ class _ChatDetailsState extends State<ChatDetails> {
super.dispose();
}
}
class _ChatColorPickerModal extends StatefulWidget {
const _ChatColorPickerModal({
required this.initialColor,
required this.scrollController,
required this.onColorSelected,
required this.chatStore,
});
final String? initialColor;
final ScrollController scrollController;
final Future<void> Function(BuildContext context, String color)
onColorSelected;
final ChatStore chatStore;
@override
State<_ChatColorPickerModal> createState() => _ChatColorPickerModalState();
}
class _ChatColorPickerModalState extends State<_ChatColorPickerModal> {
String? selectedColor;
@override
void initState() {
super.initState();
selectedColor = widget.initialColor;
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
_buildPreview(),
AnimatedScrollBorder(scrollController: widget.scrollController),
_buildColorList(),
_buildBottomBar(),
],
);
}
Widget _buildHeader() {
return const SectionHeader('Username color', isFirst: true);
}
Widget _buildPreview() {
if (selectedColor == null) return const SizedBox.shrink();
// Create a realistic preview using the current user's chat state and badges
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: _buildRealisticChatPreview(),
);
}
Widget _buildRealisticChatPreview() {
final userDetails = widget.chatStore.auth.user.details!;
final userState = widget.chatStore.userState;
// Create a mock IRC message with the user's current state
final mockTags = <String, String>{
'display-name': userDetails.displayName,
'color':
'#${chatColorValues[selectedColor!]!.toARGB32().toRadixString(16).substring(2)}',
'user-id': userDetails.id,
'mod': userState.mod ? '1' : '0',
'subscriber': userState.subscriber ? '1' : '0',
'id': 'preview-message',
'tmi-sent-ts': DateTime.now().millisecondsSinceEpoch.toString(),
};
// Copy any badges from the current user state
if (userState.raw != null) {
final userStateMessage = IRCMessage.fromString(userState.raw!);
if (userStateMessage.tags['badges'] != null) {
mockTags['badges'] = userStateMessage.tags['badges']!;
}
if (userStateMessage.tags['badge-info'] != null) {
mockTags['badge-info'] = userStateMessage.tags['badge-info']!;
}
}
final mockMessage = IRCMessage(
raw: '',
command: Command.privateMessage,
tags: mockTags,
user: userDetails.login,
message: 'How it will look in chat',
split: ['How', 'it', 'will', 'look', 'in', 'chat'],
action: false,
mention: false,
);
return Text.rich(
TextSpan(
children: mockMessage.generateSpan(
context,
assetsStore: widget.chatStore.assetsStore,
emoteScale: widget.chatStore.settings.emoteScale,
badgeScale: widget.chatStore.settings.badgeScale,
launchExternal: false, // Disable launching for preview
style: DefaultTextStyle.of(context).style,
),
),
);
}
Widget _buildColorList() {
return Expanded(
child: FrostyScrollbar(
controller: widget.scrollController,
child: ListView(
padding: EdgeInsets.zero,
controller: widget.scrollController,
children: chatColorNames
.map((colorName) => _buildColorTile(colorName))
.toList(),
),
),
);
}
Widget _buildColorTile(String colorName) {
final isSelected = selectedColor == colorName;
final displayName = colorName
.replaceAll('_', ' ')
.split(' ')
.map((word) => word[0].toUpperCase() + word.substring(1))
.join(' ');
// Apply the same color adjustment for consistent preview
final rawColor = chatColorValues[colorName]!;
final adjustedColor = adjustChatNameColor(context, rawColor);
return ListTile(
leading: Container(
width: 24,
height: 24,
decoration: BoxDecoration(color: adjustedColor, shape: BoxShape.circle),
),
title: Text(displayName),
trailing: isSelected
? Icon(Icons.check, color: Theme.of(context).colorScheme.primary)
: null,
onTap: () => setState(() => selectedColor = colorName),
);
}
Widget _buildBottomBar() {
return Column(
children: [
AnimatedScrollBorder(
scrollController: widget.scrollController,
position: ScrollBorderPosition.bottom,
),
SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Expanded(
child: TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: selectedColor == null
? null
: () => widget.onColorSelected(context, selectedColor!),
child: const Text('Save'),
),
),
],
),
),
),
],
);
}
}

View file

@ -12,13 +12,16 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store {
Computed<Iterable<String>>? _$filteredUsersComputed;
@override
Iterable<String> get filteredUsers => (_$filteredUsersComputed ??=
Computed<Iterable<String>>(() => super.filteredUsers,
name: 'ChatDetailsStoreBase.filteredUsers'))
.value;
Iterable<String> get filteredUsers =>
(_$filteredUsersComputed ??= Computed<Iterable<String>>(
() => super.filteredUsers,
name: 'ChatDetailsStoreBase.filteredUsers',
)).value;
late final _$roomStateAtom =
Atom(name: 'ChatDetailsStoreBase.roomState', context: context);
late final _$roomStateAtom = Atom(
name: 'ChatDetailsStoreBase.roomState',
context: context,
);
@override
ROOMSTATE get roomState {
@ -33,8 +36,10 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store {
});
}
late final _$showJumpButtonAtom =
Atom(name: 'ChatDetailsStoreBase.showJumpButton', context: context);
late final _$showJumpButtonAtom = Atom(
name: 'ChatDetailsStoreBase.showJumpButton',
context: context,
);
@override
bool get showJumpButton {
@ -49,8 +54,10 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store {
});
}
late final _$_filterTextAtom =
Atom(name: 'ChatDetailsStoreBase._filterText', context: context);
late final _$_filterTextAtom = Atom(
name: 'ChatDetailsStoreBase._filterText',
context: context,
);
String get filterText {
_$_filterTextAtom.reportRead();

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