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)
335 lines
7.8 KiB
Dart
335 lines
7.8 KiB
Dart
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
|
|
part of '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 _$VideoStore on VideoStoreBase, Store {
|
|
late final _$_loadingAtom = Atom(
|
|
name: 'VideoStoreBase._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: 'VideoStoreBase._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 _$_overlayVisibleAtom = Atom(
|
|
name: 'VideoStoreBase._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: 'VideoStoreBase._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: 'VideoStoreBase._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: 'VideoStoreBase._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 _$_streamQualityIndexAtom = Atom(
|
|
name: 'VideoStoreBase._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: 'VideoStoreBase._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: 'VideoStoreBase._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 _$updateStreamQualitiesAsyncAction = AsyncAction(
|
|
'VideoStoreBase.updateStreamQualities',
|
|
context: context,
|
|
);
|
|
|
|
@override
|
|
Future<void> updateStreamQualities() {
|
|
return _$updateStreamQualitiesAsyncAction.run(
|
|
() => super.updateStreamQualities(),
|
|
);
|
|
}
|
|
|
|
late final _$setStreamQualityAsyncAction = AsyncAction(
|
|
'VideoStoreBase.setStreamQuality',
|
|
context: context,
|
|
);
|
|
|
|
@override
|
|
Future<void> setStreamQuality(String newStreamQuality) {
|
|
return _$setStreamQualityAsyncAction.run(
|
|
() => super.setStreamQuality(newStreamQuality),
|
|
);
|
|
}
|
|
|
|
late final _$_setStreamQualityIndexAsyncAction = AsyncAction(
|
|
'VideoStoreBase._setStreamQualityIndex',
|
|
context: context,
|
|
);
|
|
|
|
@override
|
|
Future<void> _setStreamQualityIndex(int newStreamQualityIndex) {
|
|
return _$_setStreamQualityIndexAsyncAction.run(
|
|
() => super._setStreamQualityIndex(newStreamQualityIndex),
|
|
);
|
|
}
|
|
|
|
late final _$initVideoAsyncAction = AsyncAction(
|
|
'VideoStoreBase.initVideo',
|
|
context: context,
|
|
);
|
|
|
|
@override
|
|
Future<void> initVideo() {
|
|
return _$initVideoAsyncAction.run(() => super.initVideo());
|
|
}
|
|
|
|
late final _$updateStreamInfoAsyncAction = AsyncAction(
|
|
'VideoStoreBase.updateStreamInfo',
|
|
context: context,
|
|
);
|
|
|
|
@override
|
|
Future<void> updateStreamInfo({bool forceUpdate = false}) {
|
|
return _$updateStreamInfoAsyncAction.run(
|
|
() => super.updateStreamInfo(forceUpdate: forceUpdate),
|
|
);
|
|
}
|
|
|
|
late final _$handleRefreshAsyncAction = AsyncAction(
|
|
'VideoStoreBase.handleRefresh',
|
|
context: context,
|
|
);
|
|
|
|
@override
|
|
Future<void> handleRefresh() {
|
|
return _$handleRefreshAsyncAction.run(() => super.handleRefresh());
|
|
}
|
|
|
|
late final _$VideoStoreBaseActionController = ActionController(
|
|
name: 'VideoStoreBase',
|
|
context: context,
|
|
);
|
|
|
|
@override
|
|
void handleVideoTap() {
|
|
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
|
|
name: 'VideoStoreBase.handleVideoTap',
|
|
);
|
|
try {
|
|
return super.handleVideoTap();
|
|
} finally {
|
|
_$VideoStoreBaseActionController.endAction(_$actionInfo);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void handleAppResume() {
|
|
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
|
|
name: 'VideoStoreBase.handleAppResume',
|
|
);
|
|
try {
|
|
return super.handleAppResume();
|
|
} finally {
|
|
_$VideoStoreBaseActionController.endAction(_$actionInfo);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void handleToggleOverlay() {
|
|
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
|
|
name: 'VideoStoreBase.handleToggleOverlay',
|
|
);
|
|
try {
|
|
return super.handleToggleOverlay();
|
|
} finally {
|
|
_$VideoStoreBaseActionController.endAction(_$actionInfo);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void togglePictureInPicture() {
|
|
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
|
|
name: 'VideoStoreBase.togglePictureInPicture',
|
|
);
|
|
try {
|
|
return super.togglePictureInPicture();
|
|
} finally {
|
|
_$VideoStoreBaseActionController.endAction(_$actionInfo);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
|
|
name: 'VideoStoreBase.dispose',
|
|
);
|
|
try {
|
|
return super.dispose();
|
|
} finally {
|
|
_$VideoStoreBaseActionController.endAction(_$actionInfo);
|
|
}
|
|
}
|
|
|
|
@override
|
|
String toString() {
|
|
return '''
|
|
|
|
''';
|
|
}
|
|
}
|