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)
497 lines
12 KiB
Dart
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}
|
|
''';
|
|
}
|
|
}
|