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)
389 lines
16 KiB
Dart
389 lines
16 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
|
import 'package:frosty/constants.dart';
|
|
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
|
import 'package:frosty/screens/settings/widgets/settings_list_select.dart';
|
|
import 'package:frosty/screens/settings/widgets/settings_list_slider.dart';
|
|
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
|
|
import 'package:frosty/screens/settings/widgets/settings_muted_words.dart';
|
|
import 'package:frosty/utils/context_extensions.dart';
|
|
import 'package:frosty/widgets/frosty_cached_network_image.dart';
|
|
import 'package:frosty/widgets/section_header.dart';
|
|
import 'package:frosty/widgets/settings_page_layout.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
class ChatSettings extends StatelessWidget {
|
|
final SettingsStore settingsStore;
|
|
|
|
const ChatSettings({super.key, required this.settingsStore});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Observer(
|
|
builder: (context) => SettingsPageLayout(
|
|
children: [
|
|
const SectionHeader('General', isFirst: true),
|
|
SettingsListSwitch(
|
|
title: 'Keep screen on',
|
|
subtitle: const Text(
|
|
'Prevents the screen from sleeping while a channel is open.',
|
|
),
|
|
value: settingsStore.keepScreenAwake,
|
|
onChanged: (newValue) =>
|
|
settingsStore.keepScreenAwake = newValue,
|
|
),
|
|
SettingsListSwitch(
|
|
title: 'Autocomplete',
|
|
subtitle: const Text(
|
|
'Shows matching emotes and mentions while typing.',
|
|
),
|
|
value: settingsStore.autocomplete,
|
|
onChanged: (newValue) =>
|
|
settingsStore.autocomplete = newValue,
|
|
),
|
|
SettingsListSwitch(
|
|
title: 'Load recent messages',
|
|
subtitle: Text.rich(
|
|
TextSpan(
|
|
text:
|
|
'Loads historical recent messages in chat through a third-party API service at ',
|
|
children: [
|
|
TextSpan(
|
|
text: 'https://recent-messages.robotty.de/',
|
|
style: TextStyle(
|
|
color: context.colorScheme.primary,
|
|
decoration: TextDecoration.underline,
|
|
decorationColor: context.colorScheme.primary,
|
|
),
|
|
recognizer: TapGestureRecognizer()
|
|
..onTap = () => launchUrl(
|
|
Uri.parse(
|
|
'https://recent-messages.robotty.de/',
|
|
),
|
|
mode: settingsStore.launchUrlExternal
|
|
? LaunchMode.externalApplication
|
|
: LaunchMode.inAppBrowserView,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
value: settingsStore.showRecentMessages,
|
|
onChanged: (newValue) =>
|
|
settingsStore.showRecentMessages = newValue,
|
|
),
|
|
const SectionHeader('Message sizing'),
|
|
ExpansionTile(
|
|
title: const Text('Preview'),
|
|
children: [
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(16),
|
|
child: DefaultTextStyle(
|
|
style: DefaultTextStyle.of(
|
|
context,
|
|
).style.copyWith(fontSize: settingsStore.fontSize),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text.rich(
|
|
TextSpan(
|
|
children: [
|
|
WidgetSpan(
|
|
alignment: PlaceholderAlignment.middle,
|
|
child: FrostyCachedNetworkImage(
|
|
imageUrl:
|
|
'https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/3',
|
|
height:
|
|
defaultBadgeSize *
|
|
settingsStore.badgeScale,
|
|
width:
|
|
defaultBadgeSize *
|
|
settingsStore.badgeScale,
|
|
),
|
|
),
|
|
const TextSpan(
|
|
text: ' Badge and emote preview. ',
|
|
),
|
|
WidgetSpan(
|
|
alignment: PlaceholderAlignment.middle,
|
|
child: FrostyCachedNetworkImage(
|
|
imageUrl:
|
|
'https://static-cdn.jtvnw.net/emoticons/v2/425618/default/dark/3.0',
|
|
height:
|
|
defaultEmoteSize *
|
|
settingsStore.emoteScale,
|
|
width:
|
|
defaultEmoteSize *
|
|
settingsStore.emoteScale,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
textScaler: settingsStore.messageScale.textScaler,
|
|
),
|
|
SizedBox(height: settingsStore.messageSpacing),
|
|
Text(
|
|
'Hello! Here\'s a text preview.',
|
|
textScaler: settingsStore.messageScale.textScaler,
|
|
),
|
|
SizedBox(height: settingsStore.messageSpacing),
|
|
Text(
|
|
'And another for spacing without an emote!',
|
|
textScaler: settingsStore.messageScale.textScaler,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SettingsListSlider(
|
|
title: 'Badge scale',
|
|
trailing: '${settingsStore.badgeScale.toStringAsFixed(2)}x',
|
|
value: settingsStore.badgeScale,
|
|
min: 0.25,
|
|
max: 3.0,
|
|
divisions: 11,
|
|
onChanged: (newValue) => settingsStore.badgeScale = newValue,
|
|
),
|
|
SettingsListSlider(
|
|
title: 'Emote scale',
|
|
trailing: '${settingsStore.emoteScale.toStringAsFixed(2)}x',
|
|
value: settingsStore.emoteScale,
|
|
min: 0.25,
|
|
max: 3.0,
|
|
divisions: 11,
|
|
onChanged: (newValue) => settingsStore.emoteScale = newValue,
|
|
),
|
|
SettingsListSlider(
|
|
title: 'Message scale',
|
|
trailing: '${settingsStore.messageScale.toStringAsFixed(2)}x',
|
|
value: settingsStore.messageScale,
|
|
min: 0.5,
|
|
max: 2.0,
|
|
divisions: 6,
|
|
onChanged: (newValue) => settingsStore.messageScale = newValue,
|
|
),
|
|
SettingsListSlider(
|
|
title: 'Message spacing',
|
|
trailing:
|
|
'${settingsStore.messageSpacing.toStringAsFixed(0)}px',
|
|
value: settingsStore.messageSpacing,
|
|
max: 30.0,
|
|
divisions: 15,
|
|
onChanged: (newValue) =>
|
|
settingsStore.messageSpacing = newValue,
|
|
),
|
|
SettingsListSlider(
|
|
title: 'Font size',
|
|
trailing: settingsStore.fontSize.toInt().toString(),
|
|
value: settingsStore.fontSize,
|
|
min: 5,
|
|
max: 20,
|
|
divisions: 15,
|
|
onChanged: (newValue) => settingsStore.fontSize = newValue,
|
|
),
|
|
const SectionHeader('Message appearance'),
|
|
SettingsListSwitch(
|
|
title: 'Show deleted messages',
|
|
subtitle: const Text(
|
|
'Restores the original message of deleted messages.',
|
|
),
|
|
value: settingsStore.showDeletedMessages,
|
|
onChanged: (newValue) =>
|
|
settingsStore.showDeletedMessages = newValue,
|
|
),
|
|
SettingsListSwitch(
|
|
title: 'Show message dividers',
|
|
value: settingsStore.showChatMessageDividers,
|
|
onChanged: (newValue) =>
|
|
settingsStore.showChatMessageDividers = newValue,
|
|
),
|
|
SettingsListSelect(
|
|
title: 'Timestamps',
|
|
selectedOption:
|
|
timestampNames[settingsStore.timestampType.index],
|
|
options: timestampNames,
|
|
onChanged: (newValue) => settingsStore.timestampType =
|
|
TimestampType.values[timestampNames.indexOf(newValue)],
|
|
),
|
|
SettingsListSwitch(
|
|
title: 'Show timestamps on historical messages',
|
|
subtitle: const Text(
|
|
'Always show timestamps on messages loaded from chat history, '
|
|
'even when timestamps are disabled above.',
|
|
),
|
|
value: settingsStore.showHistoricalTimestamps,
|
|
onChanged: (newValue) =>
|
|
settingsStore.showHistoricalTimestamps = newValue,
|
|
),
|
|
SettingsListSwitch(
|
|
title: 'Focus current channel',
|
|
subtitle: const Text(
|
|
'Fades messages from other channels in shared chat and '
|
|
'merged views, so the current channel stands out.',
|
|
),
|
|
value: settingsStore.focusCurrentChannel,
|
|
onChanged: (newValue) =>
|
|
settingsStore.focusCurrentChannel = newValue,
|
|
),
|
|
const SectionHeader('Delay'),
|
|
SettingsListSwitch(
|
|
title: 'Auto-sync chat delay',
|
|
value: settingsStore.autoSyncChatDelay,
|
|
onChanged: (newValue) =>
|
|
settingsStore.autoSyncChatDelay = newValue,
|
|
),
|
|
if (!settingsStore.autoSyncChatDelay)
|
|
SettingsListSlider(
|
|
title: 'Chat delay',
|
|
trailing: '${settingsStore.chatDelay.toInt()} seconds',
|
|
subtitle:
|
|
'Adds a delay before each message is rendered in chat. ${Platform.isIOS ? '15 seconds is recommended for iOS.' : ''}',
|
|
value: settingsStore.chatDelay,
|
|
max: 30.0,
|
|
divisions: 30,
|
|
onChanged: (newValue) =>
|
|
settingsStore.chatDelay = newValue,
|
|
),
|
|
const SectionHeader('Alerts'),
|
|
SettingsListSwitch(
|
|
title: 'Highlight first-time chatters',
|
|
value: settingsStore.highlightFirstTimeChatter,
|
|
onChanged: (newValue) =>
|
|
settingsStore.highlightFirstTimeChatter = newValue,
|
|
),
|
|
SettingsListSwitch(
|
|
title: 'Show notices',
|
|
subtitle: const Text(
|
|
'Shows notices such as subs, announcements, and raids.',
|
|
),
|
|
value: settingsStore.showUserNotices,
|
|
onChanged: (newValue) =>
|
|
settingsStore.showUserNotices = newValue,
|
|
),
|
|
const SectionHeader('Layout'),
|
|
SettingsListSwitch(
|
|
title: 'Emote menu on left',
|
|
subtitle: const Text(
|
|
'Places the emote menu button on the left side.',
|
|
),
|
|
value: settingsStore.emoteMenuButtonOnLeft,
|
|
onChanged: (newValue) =>
|
|
settingsStore.emoteMenuButtonOnLeft = newValue,
|
|
),
|
|
SettingsListSwitch(
|
|
title: 'Remember chat tabs',
|
|
subtitle: const Text(
|
|
'Secondary chat tabs are kept when switching channels.',
|
|
),
|
|
value: settingsStore.persistChatTabs,
|
|
onChanged: (newValue) {
|
|
settingsStore.persistChatTabs = newValue;
|
|
if (!newValue) {
|
|
settingsStore.secondaryTabs = [];
|
|
}
|
|
},
|
|
),
|
|
const SectionHeader('Landscape'),
|
|
SettingsListSwitch(
|
|
title: 'Chat on left side',
|
|
value: settingsStore.landscapeChatLeftSide,
|
|
onChanged: (newValue) =>
|
|
settingsStore.landscapeChatLeftSide = newValue,
|
|
),
|
|
SettingsListSwitch(
|
|
title: 'Force vertical chat',
|
|
subtitle: const Text(
|
|
'Intended for tablets and larger displays.',
|
|
),
|
|
value: settingsStore.landscapeForceVerticalChat,
|
|
onChanged: (newValue) =>
|
|
settingsStore.landscapeForceVerticalChat = newValue,
|
|
),
|
|
SettingsListSelect(
|
|
title: 'Notch fill',
|
|
subtitle:
|
|
'Fills the display cutout area on the selected side.',
|
|
selectedOption:
|
|
landscapeCutoutNames[settingsStore.landscapeCutout.index],
|
|
options: landscapeCutoutNames,
|
|
onChanged: (newValue) => settingsStore.landscapeCutout =
|
|
LandscapeCutoutType.values[landscapeCutoutNames.indexOf(
|
|
newValue,
|
|
)],
|
|
),
|
|
SettingsListSlider(
|
|
title: 'Overlay chat opacity',
|
|
trailing:
|
|
'${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%',
|
|
subtitle:
|
|
'Opacity of the chat overlay in fullscreen mode.',
|
|
value: settingsStore.fullScreenChatOverlayOpacity,
|
|
divisions: 10,
|
|
onChanged: (newValue) =>
|
|
settingsStore.fullScreenChatOverlayOpacity = newValue,
|
|
),
|
|
const SectionHeader('Filtering'),
|
|
SettingsMutedWords(settingsStore: settingsStore),
|
|
SettingsListSwitch(
|
|
title: 'Match whole words',
|
|
subtitle: const Text(
|
|
'Only matches whole words instead of partial matches.',
|
|
),
|
|
value: settingsStore.matchWholeWord,
|
|
onChanged: (newValue) =>
|
|
settingsStore.matchWholeWord = newValue,
|
|
),
|
|
const SectionHeader('Emotes and badges'),
|
|
SettingsListSwitch(
|
|
title: 'Twitch emotes',
|
|
value: settingsStore.showTwitchEmotes,
|
|
onChanged: (newValue) =>
|
|
settingsStore.showTwitchEmotes = newValue,
|
|
),
|
|
SettingsListSwitch(
|
|
title: 'Twitch badges',
|
|
value: settingsStore.showTwitchBadges,
|
|
onChanged: (newValue) =>
|
|
settingsStore.showTwitchBadges = newValue,
|
|
),
|
|
SettingsListSwitch(
|
|
title: '7TV emotes',
|
|
value: settingsStore.show7TVEmotes,
|
|
onChanged: (newValue) =>
|
|
settingsStore.show7TVEmotes = newValue,
|
|
),
|
|
SettingsListSwitch(
|
|
title: 'BTTV emotes',
|
|
value: settingsStore.showBTTVEmotes,
|
|
onChanged: (newValue) =>
|
|
settingsStore.showBTTVEmotes = newValue,
|
|
),
|
|
SettingsListSwitch(
|
|
title: 'BTTV badges',
|
|
value: settingsStore.showBTTVBadges,
|
|
onChanged: (newValue) =>
|
|
settingsStore.showBTTVBadges = newValue,
|
|
),
|
|
SettingsListSwitch(
|
|
title: 'FFZ emotes',
|
|
value: settingsStore.showFFZEmotes,
|
|
onChanged: (newValue) =>
|
|
settingsStore.showFFZEmotes = newValue,
|
|
),
|
|
SettingsListSwitch(
|
|
title: 'FFZ badges',
|
|
value: settingsStore.showFFZBadges,
|
|
onChanged: (newValue) =>
|
|
settingsStore.showFFZBadges = newValue,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|