frosty/lib/screens/channel/video/native_video_store.g.dart
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

497 lines
12 KiB
Dart

// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'native_video_store.dart';
// **************************************************************************
// StoreGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
mixin _$NativeVideoStore on NativeVideoStoreBase, Store {
Computed<String>? _$streamQualityComputed;
@override
String get streamQuality => (_$streamQualityComputed ??= Computed<String>(
() => super.streamQuality,
name: 'NativeVideoStoreBase.streamQuality',
)).value;
late final _$_controllerAtom = Atom(
name: 'NativeVideoStoreBase._controller',
context: context,
);
NativeVideoPlayerController? get controller {
_$_controllerAtom.reportRead();
return super._controller;
}
@override
NativeVideoPlayerController? get _controller => controller;
@override
set _controller(NativeVideoPlayerController? value) {
_$_controllerAtom.reportWrite(value, super._controller, () {
super._controller = value;
});
}
late final _$_loadingAtom = Atom(
name: 'NativeVideoStoreBase._loading',
context: context,
);
bool get loading {
_$_loadingAtom.reportRead();
return super._loading;
}
@override
bool get _loading => loading;
@override
set _loading(bool value) {
_$_loadingAtom.reportWrite(value, super._loading, () {
super._loading = value;
});
}
late final _$_pausedAtom = Atom(
name: 'NativeVideoStoreBase._paused',
context: context,
);
bool get paused {
_$_pausedAtom.reportRead();
return super._paused;
}
@override
bool get _paused => paused;
@override
set _paused(bool value) {
_$_pausedAtom.reportWrite(value, super._paused, () {
super._paused = value;
});
}
late final _$_hasPlayedOnceAtom = Atom(
name: 'NativeVideoStoreBase._hasPlayedOnce',
context: context,
);
bool get hasPlayedOnce {
_$_hasPlayedOnceAtom.reportRead();
return super._hasPlayedOnce;
}
@override
bool get _hasPlayedOnce => hasPlayedOnce;
@override
set _hasPlayedOnce(bool value) {
_$_hasPlayedOnceAtom.reportWrite(value, super._hasPlayedOnce, () {
super._hasPlayedOnce = value;
});
}
late final _$_overlayVisibleAtom = Atom(
name: 'NativeVideoStoreBase._overlayVisible',
context: context,
);
bool get overlayVisible {
_$_overlayVisibleAtom.reportRead();
return super._overlayVisible;
}
@override
bool get _overlayVisible => overlayVisible;
@override
set _overlayVisible(bool value) {
_$_overlayVisibleAtom.reportWrite(value, super._overlayVisible, () {
super._overlayVisible = value;
});
}
late final _$_streamInfoAtom = Atom(
name: 'NativeVideoStoreBase._streamInfo',
context: context,
);
StreamTwitch? get streamInfo {
_$_streamInfoAtom.reportRead();
return super._streamInfo;
}
@override
StreamTwitch? get _streamInfo => streamInfo;
@override
set _streamInfo(StreamTwitch? value) {
_$_streamInfoAtom.reportWrite(value, super._streamInfo, () {
super._streamInfo = value;
});
}
late final _$_offlineChannelInfoAtom = Atom(
name: 'NativeVideoStoreBase._offlineChannelInfo',
context: context,
);
Channel? get offlineChannelInfo {
_$_offlineChannelInfoAtom.reportRead();
return super._offlineChannelInfo;
}
@override
Channel? get _offlineChannelInfo => offlineChannelInfo;
@override
set _offlineChannelInfo(Channel? value) {
_$_offlineChannelInfoAtom.reportWrite(value, super._offlineChannelInfo, () {
super._offlineChannelInfo = value;
});
}
late final _$_availableStreamQualitiesAtom = Atom(
name: 'NativeVideoStoreBase._availableStreamQualities',
context: context,
);
List<String> get availableStreamQualities {
_$_availableStreamQualitiesAtom.reportRead();
return super._availableStreamQualities;
}
@override
List<String> get _availableStreamQualities => availableStreamQualities;
@override
set _availableStreamQualities(List<String> value) {
_$_availableStreamQualitiesAtom.reportWrite(
value,
super._availableStreamQualities,
() {
super._availableStreamQualities = value;
},
);
}
late final _$_restrictedStreamQualitiesAtom = Atom(
name: 'NativeVideoStoreBase._restrictedStreamQualities',
context: context,
);
List<String> get restrictedStreamQualities {
_$_restrictedStreamQualitiesAtom.reportRead();
return super._restrictedStreamQualities;
}
@override
List<String> get _restrictedStreamQualities => restrictedStreamQualities;
@override
set _restrictedStreamQualities(List<String> value) {
_$_restrictedStreamQualitiesAtom.reportWrite(
value,
super._restrictedStreamQualities,
() {
super._restrictedStreamQualities = value;
},
);
}
late final _$_streamQualityIndexAtom = Atom(
name: 'NativeVideoStoreBase._streamQualityIndex',
context: context,
);
int get streamQualityIndex {
_$_streamQualityIndexAtom.reportRead();
return super._streamQualityIndex;
}
@override
int get _streamQualityIndex => streamQualityIndex;
@override
set _streamQualityIndex(int value) {
_$_streamQualityIndexAtom.reportWrite(value, super._streamQualityIndex, () {
super._streamQualityIndex = value;
});
}
late final _$_latencyAtom = Atom(
name: 'NativeVideoStoreBase._latency',
context: context,
);
String? get latency {
_$_latencyAtom.reportRead();
return super._latency;
}
@override
String? get _latency => latency;
@override
set _latency(String? value) {
_$_latencyAtom.reportWrite(value, super._latency, () {
super._latency = value;
});
}
late final _$_isInPipModeAtom = Atom(
name: 'NativeVideoStoreBase._isInPipMode',
context: context,
);
bool get isInPipMode {
_$_isInPipModeAtom.reportRead();
return super._isInPipMode;
}
@override
bool get _isInPipMode => isInPipMode;
@override
set _isInPipMode(bool value) {
_$_isInPipModeAtom.reportWrite(value, super._isInPipMode, () {
super._isInPipMode = value;
});
}
late final _$_isAudioOnlyModeAtom = Atom(
name: 'NativeVideoStoreBase._isAudioOnlyMode',
context: context,
);
bool get isAudioOnlyMode {
_$_isAudioOnlyModeAtom.reportRead();
return super._isAudioOnlyMode;
}
@override
bool get _isAudioOnlyMode => isAudioOnlyMode;
@override
set _isAudioOnlyMode(bool value) {
_$_isAudioOnlyModeAtom.reportWrite(value, super._isAudioOnlyMode, () {
super._isAudioOnlyMode = value;
});
}
late final _$_errorAtom = Atom(
name: 'NativeVideoStoreBase._error',
context: context,
);
String? get error {
_$_errorAtom.reportRead();
return super._error;
}
@override
String? get _error => error;
@override
set _error(String? value) {
_$_errorAtom.reportWrite(value, super._error, () {
super._error = value;
});
}
late final _$_initPlayerAsyncAction = AsyncAction(
'NativeVideoStoreBase._initPlayer',
context: context,
);
@override
Future<void> _initPlayer() {
return _$_initPlayerAsyncAction.run(() => super._initPlayer());
}
late final _$updateStreamQualitiesAsyncAction = AsyncAction(
'NativeVideoStoreBase.updateStreamQualities',
context: context,
);
@override
Future<void> updateStreamQualities() {
return _$updateStreamQualitiesAsyncAction.run(
() => super.updateStreamQualities(),
);
}
late final _$setStreamQualityAsyncAction = AsyncAction(
'NativeVideoStoreBase.setStreamQuality',
context: context,
);
@override
Future<void> setStreamQuality(String quality) {
return _$setStreamQualityAsyncAction.run(
() => super.setStreamQuality(quality),
);
}
late final _$_setStreamQualityIndexAsyncAction = AsyncAction(
'NativeVideoStoreBase._setStreamQualityIndex',
context: context,
);
@override
Future<void> _setStreamQualityIndex(int index) {
return _$_setStreamQualityIndexAsyncAction.run(
() => super._setStreamQualityIndex(index),
);
}
late final _$updateStreamInfoAsyncAction = AsyncAction(
'NativeVideoStoreBase.updateStreamInfo',
context: context,
);
@override
Future<void> updateStreamInfo({bool forceUpdate = false}) {
return _$updateStreamInfoAsyncAction.run(
() => super.updateStreamInfo(forceUpdate: forceUpdate),
);
}
late final _$NativeVideoStoreBaseActionController = ActionController(
name: 'NativeVideoStoreBase',
context: context,
);
@override
Future<void> _reloadCurrentController() {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
name: 'NativeVideoStoreBase._reloadCurrentController',
);
try {
return super._reloadCurrentController();
} finally {
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void handleVideoTap() {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
name: 'NativeVideoStoreBase.handleVideoTap',
);
try {
return super.handleVideoTap();
} finally {
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void handleToggleOverlay() {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
name: 'NativeVideoStoreBase.handleToggleOverlay',
);
try {
return super.handleToggleOverlay();
} finally {
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
Future<void> handleRefresh() {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
name: 'NativeVideoStoreBase.handleRefresh',
);
try {
return super.handleRefresh();
} finally {
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void togglePictureInPicture() {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
name: 'NativeVideoStoreBase.togglePictureInPicture',
);
try {
return super.togglePictureInPicture();
} finally {
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void handleAppResume() {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
name: 'NativeVideoStoreBase.handleAppResume',
);
try {
return super.handleAppResume();
} finally {
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void handleAndroidPipChanged(bool isInPip) {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
name: 'NativeVideoStoreBase.handleAndroidPipChanged',
);
try {
return super.handleAndroidPipChanged(isInPip);
} finally {
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void dispose() {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
name: 'NativeVideoStoreBase.dispose',
);
try {
return super.dispose();
} finally {
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void _resetPlayerStateForChatOnly() {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
name: 'NativeVideoStoreBase._resetPlayerStateForChatOnly',
);
try {
return super._resetPlayerStateForChatOnly();
} finally {
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void _initFreshController() {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
name: 'NativeVideoStoreBase._initFreshController',
);
try {
return super._initFreshController();
} finally {
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''
streamQuality: ${streamQuality}
''';
}
}