`_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>
* 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.
- 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>
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>
- 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>
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>
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>
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>
- 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>
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)
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>