chore(sync): merge upstream frosty 5.1.0

This commit is contained in:
kaoru 2026-06-14 19:21:55 -04:00
commit 218b5408f5
43 changed files with 1909 additions and 632 deletions

View file

@ -142,7 +142,7 @@ jobs:
fi
- name: Create draft release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
draft: true
tag_name: ${{ env.RELEASE_TAG }}

View file

@ -10,6 +10,7 @@ plugins {
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
val allowDebugSignedRelease = System.getenv("ALLOW_DEBUG_SIGNED_RELEASE") != null
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
@ -97,13 +98,21 @@ android {
// debug-signed if the keystore step is ever missing.
signingConfig = when {
keystorePropertiesFile.exists() -> signingConfigs.getByName("release")
System.getenv("ALLOW_DEBUG_SIGNED_RELEASE") != null -> signingConfigs.getByName("debug")
else -> error("key.properties missing - set ALLOW_DEBUG_SIGNED_RELEASE=1 for compile-only builds")
else -> signingConfigs.getByName("debug")
}
}
}
}
gradle.taskGraph.whenReady {
val releaseTaskRequested = allTasks.any { task ->
task.path.startsWith(":app:") && task.name.contains("Release")
}
if (releaseTaskRequested && !keystorePropertiesFile.exists() && !allowDebugSignedRelease) {
throw GradleException("key.properties missing - set ALLOW_DEBUG_SIGNED_RELEASE=1 for compile-only builds")
}
}
flutter {
source = "../.."
}

View file

@ -2,5 +2,9 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m
android.useAndroidX=true
org.gradle.caching=true
org.gradle.parallel=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
android.builtInKotlin=false
android.newDsl=false

View file

@ -1,3 +1,29 @@
## v5.1.0
June 13, 2026
### Improvements
- Added merged chat mode for multi-tab chat, with unread indicators and channel focus controls.
- Added chat settings for keeping the screen on and showing timestamps on historical messages.
- Improved chat input with inline emote rendering while typing.
- Improved chat send reliability, especially when chat delay is enabled.
- Improved WebView video stability around refreshes, stalls, high latency, fullscreen, and Picture-in-Picture.
- Kept Glacier Android-only packaging, app identity, APK release flow, Cast support, and stream proxy support.
### Bug Fixes
- Fixed recent messages failing on valueless or escaped IRC tags.
- Fixed shared chat badges and emotes lingering after leaving shared chat.
- Fixed recent searches not saving from type-to-search.
- Fixed auto-synced chat delay not applying when toggled on mid-session.
- Fixed long chat inputs overlapping the message list.
- Fixed a video refresh race that could leave a black screen with only the play button visible.
- Fixed fullscreen button and system UI state getting out of sync.
- Fixed Twitch/API auth requests that could hang instead of failing cleanly.
---
## v5.0.5
January 9, 2026

View file

@ -14,8 +14,9 @@ class UnauthorizedInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
// Check if this is a 401 Unauthorized error
if (err.response?.statusCode == 401) {
// Check if this is a 401 Unauthorized error on a Twitch Helix/OAuth request
if (err.response?.statusCode == 401 &&
_isTwitchHelixOrOAuth(err.requestOptions.uri)) {
// For token validation requests, let the error propagate so validateToken can handle it
if (err.requestOptions.uri.path.endsWith('/validate')) {
handler.next(err);
@ -89,4 +90,10 @@ class UnauthorizedInterceptor extends Interceptor {
}
static bool _isDialogShowing = false;
bool _isTwitchHelixOrOAuth(Uri uri) {
final url = uri.toString();
return url.startsWith('https://api.twitch.tv/helix') ||
url.startsWith('https://id.twitch.tv/oauth2');
}
}

View file

@ -3,6 +3,14 @@ import 'package:flutter/material.dart';
/// Twitch API client ID.
const clientId = String.fromEnvironment('CLIENT_ID');
/// SharedPreferences key for the per-channel last-selected stream quality.
/// A saved variant that does not exist on another channel falls back to Auto.
String lastStreamQualityKey(String userLogin) =>
'last_stream_quality_$userLogin';
/// Legacy global quality key. Removed on startup during SharedPreferences init.
const kLegacyLastStreamQualityKey = 'last_stream_quality';
/// Twitch API client secret.
const secret = String.fromEnvironment('SECRET');

View file

@ -13,6 +13,7 @@ import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/apis/twitch_auth_interceptor.dart';
import 'package:frosty/apis/unauthorized_interceptor.dart';
import 'package:frosty/cache_manager.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/screens/channel/channel.dart';
import 'package:frosty/screens/home/home.dart';
import 'package:frosty/screens/onboarding/onboarding_intro.dart';
@ -32,6 +33,7 @@ void main() async {
CustomCacheManager.removeOrphanedCacheFiles();
final prefs = await SharedPreferences.getInstance();
await prefs.remove(kLegacyLastStreamQualityKey);
final firstRun = prefs.getBool('first_run') ?? true;

View file

@ -176,7 +176,9 @@ class IRCMessage {
size: badgeSize,
color:
Theme.of(context).iconTheme.color?.withValues(alpha: 0.5) ??
Colors.grey.withValues(alpha: 0.5),
Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
),
),
),
@ -661,6 +663,8 @@ class IRCMessage {
bool showMessage = true,
Map<String, UserTwitch>? channelIdToUserTwitch,
TimestampType timestamp = TimestampType.disabled,
bool forceTimestamp = false,
bool showHistoricalTimestamps = false,
String? currentChannelId,
}) {
final emoteToObject = assetsStore.emoteToObject;
@ -671,7 +675,19 @@ class IRCMessage {
// The span list that will be used to render the chat message
final span = <InlineSpan>[];
_addTimestamp(span, style, timestamp);
// When the user has timestamps disabled globally, fall back to the device's
// locale preference for historical messages (opt-in via setting) and for
// forced contexts like previews.
final isHistorical = tags['historical'] == '1';
final effectiveTimestamp =
(timestamp == TimestampType.disabled &&
((isHistorical && showHistoricalTimestamps) || forceTimestamp))
? (MediaQuery.alwaysUse24HourFormatOf(context)
? TimestampType.twentyFour
: TimestampType.twelve)
: timestamp;
_addTimestamp(span, style, effectiveTimestamp);
_addHistoricalAndChannelBadges(
context,
span,
@ -680,7 +696,6 @@ class IRCMessage {
currentChannelId,
);
final isHistorical = tags['historical'] == '1';
_addUserBadges(
context,
span,

View file

@ -122,7 +122,9 @@ class _VideoChatState extends State<VideoChat>
void _handlePipDragStart(DragStartDetails details) {
// Disable drag gesture when already in PiP mode or video is not playing
if (_videoStore.isInPipMode || _videoStore.paused) return;
if (_videoStore.isInPipMode || _videoStore.paused || _videoStore.loading) {
return;
}
_animationController.stop(); // Stop any ongoing animation
setState(() {
@ -133,7 +135,10 @@ class _VideoChatState extends State<VideoChat>
}
void _handlePipDragUpdate(DragUpdateDetails details) {
if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) {
if (!_isPipDragging ||
_videoStore.isInPipMode ||
_videoStore.paused ||
_videoStore.loading) {
return;
}
@ -157,7 +162,10 @@ class _VideoChatState extends State<VideoChat>
}
void _handlePipDragEnd(DragEndDetails details) {
if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) {
if (!_isPipDragging ||
_videoStore.isInPipMode ||
_videoStore.paused ||
_videoStore.loading) {
return;
}
@ -327,7 +335,9 @@ class _VideoChatState extends State<VideoChat>
settingsStore: settingsStore,
);
if (_videoStore.paused || _videoStore.streamInfo == null) {
if (_videoStore.paused ||
_videoStore.streamInfo == null ||
_videoStore.loading) {
return videoOverlay;
}
@ -347,15 +357,44 @@ class _VideoChatState extends State<VideoChat>
),
);
final video = Observer(
builder: (context) {
return Stack(
children: [
player,
if (_videoStore.settingsStore.showOverlay) overlay,
],
);
},
final video = Stack(
children: [
player,
Positioned.fill(
child: Observer(
builder: (_) => AnimatedOpacity(
opacity: _videoStore.loading ? 1.0 : 0.0,
duration: _videoStore.loading
? Duration.zero
: const Duration(milliseconds: 300),
curve: Curves.easeOut,
child: IgnorePointer(
child: TickerMode(
enabled: _videoStore.loading,
child: const ColoredBox(
color: Colors.black,
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: Colors.white38,
strokeWidth: 2,
),
),
),
),
),
),
),
),
),
Observer(
builder: (_) => _videoStore.settingsStore.showOverlay
? overlay
: const SizedBox.shrink(),
),
],
);
final chat = Observer(

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart';
import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart';
@ -11,6 +12,10 @@ import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/frosty_page_view.dart';
import 'package:frosty/widgets/frosty_scrollbar.dart';
/// ~2x default to keep richer message widgets (emotes, badges, replies) built
/// ahead of fast scroll-back without holding the entire history.
const _chatCacheExtent = ScrollCacheExtent.pixels(500.0);
class Chat extends StatelessWidget {
final ChatStore chatStore;
final EdgeInsetsGeometry? listPadding;
@ -140,16 +145,15 @@ class Chat extends StatelessWidget {
return ListView.builder(
reverse: true,
padding: (listPadding ?? EdgeInsets.zero).add(
EdgeInsets.only(
bottom: chatStore.bottomBarHeight + bottomPadding,
),
EdgeInsets.only(bottom: chatStore.bottomBarHeight + bottomPadding),
),
addAutomaticKeepAlives: false,
scrollCacheExtent: _chatCacheExtent,
controller: scrollController,
itemCount: chatStore.renderMessages.length,
itemBuilder: (context, index) => ChatMessage(
ircMessage: chatStore.renderMessages[
chatStore.renderMessages.length - 1 - index],
ircMessage: chatStore
.renderMessages[chatStore.renderMessages.length - 1 - index],
chatStore: chatStore,
),
);
@ -160,23 +164,20 @@ class Chat extends StatelessWidget {
double bottomPadding,
) {
final mergedMessages = chatTabsStore!.mergedMessages;
final channelIdToUserTwitch =
chatTabsStore!.mergedChannelIdToUserTwitch;
final channelIdToUserTwitch = chatTabsStore!.mergedChannelIdToUserTwitch;
final currentChannelId = chatTabsStore!.activeTab.channelId;
return ListView.builder(
reverse: true,
padding: (listPadding ?? EdgeInsets.zero).add(
EdgeInsets.only(
bottom: chatStore.bottomBarHeight + bottomPadding,
),
EdgeInsets.only(bottom: chatStore.bottomBarHeight + bottomPadding),
),
addAutomaticKeepAlives: false,
scrollCacheExtent: _chatCacheExtent,
controller: scrollController,
itemCount: mergedMessages.length,
itemBuilder: (context, index) {
final merged =
mergedMessages[mergedMessages.length - 1 - index];
final merged = mergedMessages[mergedMessages.length - 1 - index];
return ChatMessage(
ircMessage: merged.ircMessage,
chatStore: merged.chatStore,
@ -211,8 +212,7 @@ class Chat extends StatelessWidget {
right: 4,
bottom:
chatStore.bottomBarHeight +
(chatStore.assetsStore.showEmoteMenu ||
isHorizontalLandscape
(chatStore.assetsStore.showEmoteMenu || isHorizontalLandscape
? 0
: MediaQuery.of(context).padding.bottom),
),
@ -236,17 +236,13 @@ class Chat extends StatelessWidget {
? null
: ElevatedButton.icon(
onPressed: onResume,
icon: const Icon(
Icons.arrow_downward_rounded,
),
icon: const Icon(Icons.arrow_downward_rounded),
label: Text(
bufferCount > 0
? '$bufferCount new ${bufferCount == 1 ? 'message' : 'messages'}'
: 'Resume scroll',
style: const TextStyle(
fontFeatures: [
FontFeature.tabularFigures(),
],
fontFeatures: [FontFeature.tabularFigures()],
),
),
),
@ -278,17 +274,13 @@ class Chat extends StatelessWidget {
child: FrostyPageView(
headers: [
'Recent',
if (chatStore.settings.showTwitchEmotes)
'Twitch',
if (chatStore.settings.showTwitchEmotes) 'Twitch',
if (chatStore.settings.show7TVEmotes) '7TV',
if (chatStore.settings.showBTTVEmotes) 'BTTV',
if (chatStore.settings.showFFZEmotes) 'FFZ',
],
tabActions: {
if (chatStore
.assetsStore
.recentEmotes
.isNotEmpty)
if (chatStore.assetsStore.recentEmotes.isNotEmpty)
0: IconButton(
onPressed: () {
chatStore.assetsStore.recentEmotes.clear();
@ -296,9 +288,7 @@ class Chat extends StatelessWidget {
'Recent emotes cleared',
);
},
icon: const Icon(
Icons.delete_outline_rounded,
),
icon: const Icon(Icons.delete_outline_rounded),
tooltip: 'Clear recent emotes',
iconSize: 20,
),

View file

@ -171,7 +171,7 @@ class ChatModes extends StatelessWidget {
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
],

View file

@ -14,6 +14,7 @@ import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/utils.dart';
import 'package:mobx/mobx.dart';
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
part 'chat_store.g.dart';
@ -65,32 +66,41 @@ abstract class ChatStoreBase with Store {
'msg_bad_characters',
};
/// Base height of the bottom bar (input field area).
/// Fallback height used to pad the message list for one frame, until the
/// real bottom bar reports its measured height via [setBottomBarHeight].
static const _baseBottomBarHeight = 68.0;
/// Height of the autocomplete bar (SizedBox + Divider).
static const _autocompleteHeight = 51.0;
/// Server-initiated graceful reconnect notice. Twitch sends this untagged
/// before maintenance so clients can re-establish ahead of the close.
static const _serverReconnectNotice = ':tmi.twitch.tv RECONNECT';
/// Height of the reply bar (Container + Divider).
static const _replyBarHeight = 41.0;
/// Muted words pre-normalized (trimmed, lowercased, empties dropped) by a
/// reaction on the setting — avoids re-normalizing on every message.
var _normalizedMutedWords = <String>[];
/// Checks if IRC data contains a command that should bypass the chat delay.
/// Returns true if any message in the data contains a bypass command.
bool _shouldBypassDelay(String data) {
for (final message in data.trimRight().split('\r\n')) {
if (message.startsWith('@')) {
final parts = message.split(' ');
if (parts.length >= 3 && _delayBypassCommands.contains(parts[2])) {
return true;
}
}
}
return false;
/// Whether a single IRC [line] should bypass the chat delay and be handled
/// immediately. Displayable chat content (PRIVMSG/USERNOTICE and the
/// moderation CLEARCHAT/CLEARMSG) is delayed, so it stays in sync with the
/// delayed video. Everything else is handled at once: control/state messages
/// (USERSTATE/ROOMSTATE/GLOBALUSERSTATE), NOTICE (so a send rejection surfaces
/// immediately instead of one chat-delay later), the reconnect notice, and
/// any untagged server line (PING keepalives, CAP, JOIN, the welcome banner)
/// — delaying those would stall the send-ack, drop the PONG, or never start
/// the buffer drain.
bool _lineShouldBypassDelay(String line) {
if (line.isEmpty || line == _serverReconnectNotice) return true;
// Displayable chat content is always IRCv3-tagged; an untagged line is
// never delayed.
if (!line.startsWith('@')) return true;
final parts = line.split(' ');
return parts.length >= 3 && _delayBypassCommands.contains(parts[2]);
}
int get _chatDelaySeconds => settings.effectiveChatDelay.toInt();
Duration get _chatDelay =>
Duration(milliseconds: (settings.effectiveChatDelay * 1000).round());
bool get _hasActiveChatDelay => settings.showVideo && _chatDelaySeconds > 0;
bool get _hasActiveChatDelay =>
settings.showVideo && _chatDelay > Duration.zero;
final TwitchApi twitchApi;
@ -130,6 +140,12 @@ abstract class ChatStoreBase with Store {
static const _maxRetries = 5;
/// Ping interval for both the IRC and 7TV WebSocket sockets. Catches
/// zombie connections (silent network changes, OS suspends) without
/// waking the radio every 15s. Twitch's own recommended keepalive tops
/// out at 600s; community libs (e.g. dank-twitch-irc) sit around 30s.
static const _pingInterval = Duration(seconds: 60);
// The retry counter for exponential backoff.
var _retries = 0;
@ -157,6 +173,12 @@ abstract class ChatStoreBase with Store {
/// Requested message to be sent by the user. Will only be sent on receipt of a USERNOTICE command.
IRCMessage? toSend;
/// Raw text of the most recent outgoing message, retained until the send is
/// confirmed. Lets the USERSTATE handler build the local echo even when the
/// message was sent before this session's first USERSTATE arrived (so
/// [toSend] couldn't be built up front).
String? _pendingSentMessageText;
/// The list of reaction disposer functions that will be used later when disposing.
final reactions = <ReactionDisposer>[];
@ -299,25 +321,26 @@ abstract class ChatStoreBase with Store {
.toList();
}
/// Current bottom bar height based on visible overlays.
/// Used to dynamically adjust chat message list padding.
/// Measured height of the rendered bottom bar (input plus any reply or
/// autocomplete overlays). The bar reports this via [setBottomBarHeight] so
/// the message list's bottom padding tracks the bar's *true* height —
/// including multi-line input growth and OS text scaling, which fixed
/// per-overlay constants silently undercount.
final _measuredBottomBarHeight = Observable<double?>(null);
/// Current bottom bar height, used to dynamically pad the message list so the
/// bar never overlaps messages. Falls back to the single-line base height
/// until the real bar reports its first measurement.
@computed
double get bottomBarHeight {
var height = _baseBottomBarHeight;
double get bottomBarHeight =>
_measuredBottomBarHeight.value ?? _baseBottomBarHeight;
// Add reply bar height when replying to a message.
if (replyingToMessage != null) {
height += _replyBarHeight;
}
// Add autocomplete bar height when showing matches.
if (settings.autocomplete &&
((_showEmoteAutocomplete && matchingEmotes.isNotEmpty) ||
(_showMentionAutocomplete && matchingChatters.isNotEmpty))) {
height += _autocompleteHeight;
}
return height;
/// Reports the bar's real rendered height. Sub-pixel changes are ignored to
/// avoid needless message-list relayouts.
void setBottomBarHeight(double height) {
final current = _measuredBottomBarHeight.value;
if (current != null && (current - height).abs() < 0.5) return;
runInAction(() => _measuredBottomBarHeight.value = height);
}
ChatStoreBase({
@ -336,6 +359,17 @@ abstract class ChatStoreBase with Store {
reaction((_) => auth.isLoggedIn, (_) => _channel?.sink.close(1000)),
);
// Normalize muted words once per settings change instead of per message
// — the filter runs in the IRC hot path (50+ msgs/s in busy channels).
reactions.add(
reaction((_) => settings.mutedWords.toList(), (List<String> words) {
_normalizedMutedWords = words
.map((word) => word.trim().toLowerCase())
.where((word) => word.isNotEmpty)
.toList();
}, fireImmediately: true),
);
reactions.add(
reaction(
(_) => [
@ -448,7 +482,7 @@ abstract class ChatStoreBase with Store {
) {
queue.add(
_DelayedCallback(
releaseAt: DateTime.now().add(Duration(seconds: _chatDelaySeconds)),
releaseAt: DateTime.now().add(_chatDelay),
callback: callback,
),
);
@ -517,6 +551,15 @@ abstract class ChatStoreBase with Store {
// To account for this, split by CRLF, then loop and process each message.
for (final message in data.trimRight().split('\r\n')) {
// debugPrint('$message\n');
if (message == _serverReconnectNotice) {
// Twitch is about to close the socket for maintenance — close it
// ourselves so the existing onDone path reconnects with no backoff
// (first retry is immediate) instead of waiting for the server to
// drop us mid-message.
_channel?.sink.close(1000);
continue;
}
if (message.startsWith('@')) {
// Parse defensively: a single malformed message must never abort the
// loop, or one bad frame would drop every other message batched with
@ -548,19 +591,17 @@ abstract class ChatStoreBase with Store {
continue;
}
// Filter messages containing any muted words.
if (parsedIRCMessage.message != null) {
final List<String> mutedWords = settings.mutedWords;
// check if the message contains any of the muted words
for (final word in mutedWords) {
if (parsedIRCMessage.message!
.toLowerCase()
.split(settings.matchWholeWord ? ' ' : '')
.contains(word.toLowerCase())) {
return;
}
}
// Filter messages containing any muted words. Must use `continue`
// (not `return`) — a single IRC frame can batch many messages, and
// returning would drop the rest of the frame with the matched one.
if (parsedIRCMessage.message != null &&
_normalizedMutedWords.isNotEmpty) {
final msg = parsedIRCMessage.message!.toLowerCase();
final words = settings.matchWholeWord ? msg.split(' ') : null;
final muted = _normalizedMutedWords.any(
(word) => words != null ? words.contains(word) : msg.contains(word),
);
if (muted) continue;
}
switch (parsedIRCMessage.command) {
@ -576,6 +617,9 @@ abstract class ChatStoreBase with Store {
_sendingTimeoutTimer?.cancel();
_isWaitingForAck = false;
toSend = null;
// Drop the retained text so a later USERSTATE can't render the
// rejected message as a deferred echo.
_pendingSentMessageText = null;
// Show notification with Twitch's rejection message
if (parsedIRCMessage.message != null) {
@ -614,7 +658,7 @@ abstract class ChatStoreBase with Store {
!_isInSharedChatMode &&
assetsStore.hasLoadedSharedChatAssets) {
// On leaving shared chat, drop the participants' merged emotes and
// badges and restore just this channel's assets; otherwise the
// badges and restore just this channel's assets — otherwise the
// other streamer's emote set and badges linger (#522). Guarded on
// having actually loaded shared assets so a flapping tag can't
// trigger redundant refetches.
@ -644,23 +688,13 @@ abstract class ChatStoreBase with Store {
case Command.userState:
_userState = _userState.fromIRCMessage(parsedIRCMessage);
// USERSTATE arrival confirms message was sent, even without id tag
if (toSend != null) {
final messageId = parsedIRCMessage.tags['id'];
if (messageId != null) {
toSend!.tags['id'] = messageId;
} else {
// Fallback ID for edge cases where Twitch doesn't provide one
toSend!.tags['id'] =
'local-${DateTime.now().millisecondsSinceEpoch}';
}
messageBuffer.add(toSend!);
toSend = null;
// Reset sending state and clear text/reply after successful confirmation
_sendingTimeoutTimer?.cancel();
_isWaitingForAck = false;
textController.clear();
replyingToMessage = null;
// A USERSTATE following our PRIVMSG confirms the send. Finalize
// regardless of whether a local echo was pre-built — gating this on
// `toSend` left the player stuck "sending" (and firing a false
// "may not have been sent") whenever the message went out before
// this session's first USERSTATE populated `_userState`.
if (_isWaitingForAck) {
_finalizeSend(parsedIRCMessage.tags['id']);
}
break;
case Command.globalUserState:
@ -824,8 +858,9 @@ abstract class ChatStoreBase with Store {
_sevenTVChannel?.sink.close(1000);
_clearPendingDelayedCallbacks(clearChat: false);
_sevenTVChannel = WebSocketChannel.connect(
_sevenTVChannel = IOWebSocketChannel.connect(
Uri.parse('wss://events.7tv.io/v3'),
pingInterval: _pingInterval,
);
void listener(dynamic data) {
@ -905,8 +940,9 @@ abstract class ChatStoreBase with Store {
_clearPendingDelayedCallbacks(clearSevenTV: false);
_channel?.sink.close(1000);
_channel = WebSocketChannel.connect(
_channel = IOWebSocketChannel.connect(
Uri.parse('wss://irc-ws.chat.twitch.tv:443'),
pingInterval: _pingInterval,
);
// Only show chat delay countdown on initial connection or video toggle, not on reconnects
@ -919,13 +955,27 @@ abstract class ChatStoreBase with Store {
(data) {
final dataStr = data.toString();
// Process immediately if delay is disabled or command should bypass delay
if (!_hasActiveChatDelay || _shouldBypassDelay(dataStr)) {
// No active delay: process the whole frame immediately.
if (!_hasActiveChatDelay) {
_handleIRCData(dataStr);
} else {
_enqueueDelayedCallback(_pendingChatCallbacks, () {
_handleIRCData(dataStr);
});
return;
}
// Dispatch each line individually so a bypass-eligible control message
// isn't dragged behind the delay by chat lines batched in the same
// frame — and, conversely, so a delayed chat line can't hold up a
// PING/USERSTATE that shares the frame.
for (final line in dataStr.split('\r\n')) {
final trimmed = line.trimRight();
if (trimmed.isEmpty) continue;
if (_lineShouldBypassDelay(trimmed)) {
_handleIRCData(trimmed);
} else {
_enqueueDelayedCallback(
_pendingChatCallbacks,
() => _handleIRCData(trimmed),
);
}
}
},
onError: (error) {
@ -941,6 +991,14 @@ abstract class ChatStoreBase with Store {
_cancelChatDelayCountdown();
_chatDelaySyncCompleted = false;
// Reset send state — any in-flight message is unrecoverable across
// a disconnect, so don't leave the user blocked waiting for an ack
// that will never arrive.
_sendingTimeoutTimer?.cancel();
_isWaitingForAck = false;
toSend = null;
_pendingSentMessageText = null;
if (_shouldDisconnect) {
_sevenTVChannel?.sink.close(1000);
return;
@ -1111,7 +1169,7 @@ abstract class ChatStoreBase with Store {
messageBuffer.clear();
}
var remainingSeconds = _chatDelaySeconds;
var remainingSeconds = _chatDelay.inSeconds;
// Create and store reference to the countdown message
_countdownMessage = IRCMessage.createNotice(
@ -1196,53 +1254,93 @@ abstract class ChatStoreBase with Store {
// Text field and reply state cleared on USERSTATE confirmation (not optimistically)
// to preserve message text if Twitch rejects it (e.g., slow mode)
// Start timeout timer in case server doesn't respond (network failure)
// Must be outside userStateString conditional to always have a safety net
// Fallback in case the confirming USERSTATE never arrives. Twitch can omit
// or drop the USERSTATE that acknowledges a send — intermittently, and more
// often under a long chat delay — even though the message was delivered.
// A missing ack is therefore NOT evidence of failure, so rather than cry
// wolf with a "may not have been sent" error (which pushes the user to
// resend and double-post), optimistically finalize the send: render the
// local echo and clear the pending state. Genuine rejections almost always
// arrive first — the rejection NOTICE bypasses the chat delay, so it lands
// well within this window and clears the pending send before the timeout. A
// rejection arriving after the timeout has finalized is treated as sent (the
// echo stays), an accepted edge given how early NOTICEs arrive. Scaled by
// the chat delay so a legitimately delayed USERSTATE is given time to land.
_sendingTimeoutTimer?.cancel();
_sendingTimeoutTimer = Timer(const Duration(seconds: 10), () {
// Guard against execution after disposal
if (_shouldDisconnect) return;
if (_isWaitingForAck) {
_isWaitingForAck = false;
toSend = null;
HapticFeedback.heavyImpact();
updateNotification('Message may not have been sent. Please try again.');
}
_sendingTimeoutTimer = Timer(const Duration(seconds: 10) + _chatDelay, () {
if (_shouldDisconnect || !_isWaitingForAck) return;
runInAction(() => _finalizeSend(null));
});
// Obtain the logged-in user's appearance in chat with USERSTATE and create the full message to render.
var userStateString = _userState.raw;
if (userStateString != null) {
if (message.length > 3 && message.substring(0, 3) == '/me') {
userStateString +=
' :\x01ACTION ${message.replaceRange(0, 3, '').trim()}\x01';
} else {
userStateString +=
' :${replyingToMessage?.tags['display-name'] != null ? '@${replyingToMessage!.tags['display-name']} ' : ''}${message.trim()}';
}
// Build the optimistic local echo from the current USERSTATE. If the
// message is sent before this session's first USERSTATE arrives,
// _buildLocalEcho returns null and the echo is built later, when the
// confirming USERSTATE lands (see the Command.userState handler). The raw
// text is retained so that deferred build is possible.
_pendingSentMessageText = message;
toSend = _buildLocalEcho(message);
}
final userChatMessage = IRCMessage.fromString(userStateString);
userChatMessage.localEmotes?.addAll(assetsStore.userEmoteToObject);
if (auth.isLoggedIn && auth.user.details != null) {
userChatMessage.tags['user-id'] = auth.user.details!.id;
}
if (replyingToMessage != null && replyingToMessage!.tags['id'] != null) {
userChatMessage.tags['reply-parent-msg-id'] =
replyingToMessage!.tags['id']!;
userChatMessage.tags['reply-parent-display-name'] =
replyingToMessage!.tags['display-name'] ?? '';
userChatMessage.tags['reply-parent-msg-body'] =
replyingToMessage!.message ?? '';
}
// Ensure a timestamp so merged-mode sorting works correctly.
userChatMessage.tags['tmi-sent-ts'] ??=
'${DateTime.now().millisecondsSinceEpoch}';
toSend = userChatMessage;
/// Renders the pending outgoing message's local echo (building it lazily if
/// it couldn't be built at send time) and clears all send-pending state.
/// Shared by the USERSTATE confirmation and the optimistic timeout fallback.
/// [serverMessageId] is the `id` tag from the confirming USERSTATE, or null
/// when finalizing optimistically without one.
void _finalizeSend(String? serverMessageId) {
toSend ??= _pendingSentMessageText != null
? _buildLocalEcho(_pendingSentMessageText!)
: null;
if (toSend != null) {
// Prefer the server id; fall back to any id already set, then a local one.
toSend!.tags['id'] =
serverMessageId ??
toSend!.tags['id'] ??
'local-${DateTime.now().millisecondsSinceEpoch}';
messageBuffer.add(toSend!);
toSend = null;
}
_pendingSentMessageText = null;
_sendingTimeoutTimer?.cancel();
_isWaitingForAck = false;
textController.clear();
replyingToMessage = null;
}
/// Builds the optimistic local echo of an outgoing [message] from the current
/// USERSTATE, or null if no USERSTATE has been received yet this session
/// (the echo can't be rendered without the user's badges/color/display-name).
IRCMessage? _buildLocalEcho(String message) {
var userStateString = _userState.raw;
if (userStateString == null) return null;
if (message == '/me' || message.startsWith('/me ')) {
userStateString +=
' :\x01ACTION ${message.replaceRange(0, 3, '').trim()}\x01';
} else {
userStateString +=
' :${replyingToMessage?.tags['display-name'] != null ? '@${replyingToMessage!.tags['display-name']} ' : ''}${message.trim()}';
}
final userChatMessage = IRCMessage.fromString(userStateString);
userChatMessage.localEmotes?.addAll(assetsStore.userEmoteToObject);
if (auth.isLoggedIn && auth.user.details != null) {
userChatMessage.tags['user-id'] = auth.user.details!.id;
}
if (replyingToMessage != null && replyingToMessage!.tags['id'] != null) {
userChatMessage.tags['reply-parent-msg-id'] =
replyingToMessage!.tags['id']!;
userChatMessage.tags['reply-parent-display-name'] =
replyingToMessage!.tags['display-name'] ?? '';
userChatMessage.tags['reply-parent-msg-body'] =
replyingToMessage!.message ?? '';
}
// Ensure a timestamp so merged-mode sorting works correctly.
userChatMessage.tags['tmi-sent-ts'] ??=
'${DateTime.now().millisecondsSinceEpoch}';
return userChatMessage;
}
/// Adds the given [emote] to the chat textfield.

View file

@ -55,6 +55,10 @@ class ChatTabInfo {
/// The ChatStore for this tab. Null until the tab is activated (lazy loading).
ChatStore? chatStore;
/// The id of the most recent message visible the last time the user viewed
/// this tab. Used to compute unread state — null means "no baseline yet".
String? lastSeenMessageId;
/// Whether this is the primary tab (first tab, cannot be removed).
final bool isPrimary;
@ -184,8 +188,9 @@ abstract class ChatTabsStoreBase with Store {
int? tailCount,
}) {
final msgs = store.messages;
final start =
tailCount != null && msgs.length > tailCount ? msgs.length - tailCount : 0;
final start = tailCount != null && msgs.length > tailCount
? msgs.length - tailCount
: 0;
for (var i = start; i < msgs.length; i++) {
final id = msgs[i].tags['id'];
if (id != null && !seenIds.add(id)) continue;
@ -221,7 +226,12 @@ abstract class ChatTabsStoreBase with Store {
final seenIds = <String>{};
for (final tab in _tabs) {
if (tab.chatStore == null) continue;
_collectMessages(tab.chatStore!, recent, seenIds, tailCount: _mergedRenderLimit);
_collectMessages(
tab.chatStore!,
recent,
seenIds,
tailCount: _mergedRenderLimit,
);
}
recent.sort(_compareByTimestamp);
if (recent.length > _mergedRenderLimit) {
@ -256,7 +266,8 @@ abstract class ChatTabsStoreBase with Store {
for (final tab in _tabs) {
if (tab.chatStore != null) {
total +=
tab.chatStore!.messages.length + tab.chatStore!.messageBuffer.length;
tab.chatStore!.messages.length +
tab.chatStore!.messageBuffer.length;
}
}
return total;
@ -515,10 +526,9 @@ abstract class ChatTabsStoreBase with Store {
),
);
// Switch to the new tab (this will activate it)
// Switch to the new tab, snapshotting the outgoing tab's lastSeenMessageId.
final newIndex = _tabs.length - 1;
activateTab(newIndex);
activeTabIndex = newIndex;
setActiveTab(newIndex);
// If in merged mode, fetch the new tab's channel profile
if (mergedMode) {
@ -615,8 +625,6 @@ abstract class ChatTabsStoreBase with Store {
/// Reorders a tab from oldIndex to newIndex.
/// The primary tab (index 0) cannot be moved, and no tab can be placed before it.
/// The caller must pass an insertion index already adjusted for Flutter's
/// ReorderableListView remove-then-insert behavior.
@action
void reorderTab(int oldIndex, int newIndex) {
// Primary tab (index 0) cannot be moved, and nothing can move before it
@ -641,6 +649,30 @@ abstract class ChatTabsStoreBase with Store {
_syncSecondaryTabsToSettings();
}
/// Returns the last message id in [messages] that has a non-null tag id,
/// or null if no such message exists. Skips system messages without ids.
String? _latestMessageId(List<IRCMessage> messages) {
for (var i = messages.length - 1; i >= 0; i--) {
final id = messages[i].tags['id'];
if (id != null) return id;
}
return null;
}
/// True if [index] has unread messages since the user last viewed it.
/// Always false for the active tab and in merged mode.
bool hasUnreadMessages(int index) {
if (mergedMode) return false;
if (index == activeTabIndex) return false;
if (index < 0 || index >= _tabs.length) return false;
final tab = _tabs[index];
final store = tab.chatStore;
if (store == null) return false;
final latest = _latestMessageId(store.messages);
if (latest == null) return false;
return latest != tab.lastSeenMessageId;
}
/// Sets the active tab to the given index.
///
/// When [silent] is true, the current tab's draft text and reply state are
@ -649,6 +681,18 @@ abstract class ChatTabsStoreBase with Store {
@action
void setActiveTab(int index, {bool silent = false}) {
if (index >= 0 && index < _tabs.length) {
// Snapshot the outgoing tab's most recent message id so the unread
// dot for that tab clears, and only future messages count as unread.
if (index != activeTabIndex &&
activeTabIndex >= 0 &&
activeTabIndex < _tabs.length) {
final outgoing = _tabs[activeTabIndex];
final outStore = outgoing.chatStore;
if (outStore != null) {
outgoing.lastSeenMessageId = _latestMessageId(outStore.messages);
}
}
// Clear text input and emote menu when switching tabs.
// Skip in merged mode — tabs act as send-target selectors, not
// view switches, so draft/reply state should be preserved.

View file

@ -1,3 +1,4 @@
import 'package:extended_text_field/extended_text_field.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
@ -8,7 +9,9 @@ import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:frosty/widgets/blurred_container.dart';
import 'package:frosty/widgets/chat_input/emote_text_span_builder.dart';
import 'package:frosty/widgets/frosty_cached_network_image.dart';
import 'package:frosty/widgets/measure_size.dart';
class ChatBottomBar extends StatelessWidget {
final ChatStore chatStore;
@ -262,12 +265,21 @@ class ChatBottomBar extends StatelessWidget {
);
}
: null,
child: TextField(
child: ExtendedTextField(
textInputAction: TextInputAction.send,
focusNode: chatStore.textFieldFocusNode,
minLines: 1,
maxLines: 3,
enabled: isEnabled,
specialTextSpanBuilder: EmoteTextSpanBuilder(
emoteToObject:
chatStore.assetsStore.emoteToObject,
userEmoteToObject:
chatStore.assetsStore.userEmoteToObject,
emoteSize:
chatStore.settings.emoteScale *
defaultEmoteSize,
),
decoration: InputDecoration(
prefixIcon:
chatStore.settings.emoteMenuButtonOnLeft
@ -295,12 +307,12 @@ class ChatBottomBar extends StatelessWidget {
? 'Sending...'
: chatStore.replyingToMessage != null
? channelDisplayName != null
? 'Reply in ${channelDisplayName!}'
: 'Reply'
? 'Reply in ${channelDisplayName!}'
: 'Reply'
: hasChatDelay
? channelDisplayName != null
? 'Chat in ${channelDisplayName!} (${effectiveChatDelay.toInt()}s delay)'
: 'Chat (${effectiveChatDelay.toInt()}s delay)'
? 'Chat in ${channelDisplayName!} (${effectiveChatDelay.toInt()}s delay)'
: 'Chat (${effectiveChatDelay.toInt()}s delay)'
: channelDisplayName != null
? 'Chat in ${channelDisplayName!}'
: 'Chat',
@ -382,6 +394,13 @@ class ChatBottomBar extends StatelessWidget {
final needsBottomPadding =
!chatStore.assetsStore.showEmoteMenu && !isHorizontalLandscape;
// Measured once here; the list pads itself by this height so the bar
// never overlaps messages. Shared by both container branches below.
final measuredBar = MeasureSize(
onChange: (size) => chatStore.setBottomBarHeight(size.height),
child: bottomBarContent,
);
return isFullscreenOverlay
? Padding(
padding: EdgeInsets.only(
@ -389,7 +408,7 @@ class ChatBottomBar extends StatelessWidget {
? MediaQuery.of(context).padding.bottom
: 0,
),
child: bottomBarContent,
child: measuredBar,
)
: BlurredContainer(
gradientDirection: GradientDirection.down,
@ -398,7 +417,7 @@ class ChatBottomBar extends StatelessWidget {
? MediaQuery.of(context).padding.bottom
: 0,
),
child: bottomBarContent,
child: measuredBar,
);
},
);

View file

@ -17,6 +17,7 @@ class ChatMessage extends StatelessWidget {
final IRCMessage ircMessage;
final ChatStore chatStore;
final bool isModal;
final bool forceTimestamp;
final bool showReplyHeader;
final bool isInReplyThread;
@ -39,6 +40,7 @@ class ChatMessage extends StatelessWidget {
required this.ircMessage,
required this.chatStore,
this.isModal = false,
this.forceTimestamp = false,
this.showReplyHeader = true,
this.isInReplyThread = false,
this.overrideChannelIdToUserTwitch,
@ -65,6 +67,7 @@ class ChatMessage extends StatelessWidget {
username: ircMessage.user!,
userId: ircMessage.tags['user-id']!,
displayName: ircMessage.tags['display-name']!,
onActivateSourceTab: onActivateSourceTab,
),
);
}
@ -84,6 +87,7 @@ class ChatMessage extends StatelessWidget {
username: user.login,
userId: user.id,
displayName: user.displayName,
onActivateSourceTab: onActivateSourceTab,
),
);
}
@ -126,7 +130,11 @@ class ChatMessage extends StatelessWidget {
badgeScale: chatStore.settings.badgeScale,
launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType,
channelIdToUserTwitch: overrideChannelIdToUserTwitch ??
forceTimestamp: forceTimestamp,
showHistoricalTimestamps:
chatStore.settings.showHistoricalTimestamps,
channelIdToUserTwitch:
overrideChannelIdToUserTwitch ??
chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId:
overrideCurrentChannelId ?? chatStore.channelId,
@ -234,7 +242,11 @@ class ChatMessage extends StatelessWidget {
badgeScale: chatStore.settings.badgeScale,
launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType,
channelIdToUserTwitch: overrideChannelIdToUserTwitch ??
forceTimestamp: forceTimestamp,
showHistoricalTimestamps:
chatStore.settings.showHistoricalTimestamps,
channelIdToUserTwitch:
overrideChannelIdToUserTwitch ??
chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId:
overrideCurrentChannelId ?? chatStore.channelId,
@ -389,9 +401,12 @@ class ChatMessage extends StatelessWidget {
},
launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType,
forceTimestamp: forceTimestamp,
showHistoricalTimestamps:
chatStore.settings.showHistoricalTimestamps,
channelIdToUserTwitch:
overrideChannelIdToUserTwitch ??
chatStore.assetsStore.channelIdToUserTwitch,
chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId:
overrideCurrentChannelId ?? chatStore.channelId,
),
@ -482,10 +497,14 @@ class ChatMessage extends StatelessWidget {
messageHeaderIcon = Icon(
Icons.campaign_rounded,
size: messageHeaderIconSize,
color: messageHeaderTextColor,
);
messageHeader = const Text(
messageHeader = Text(
'Announcement',
style: TextStyle(fontWeight: messageHeaderFontWeight),
style: TextStyle(
fontWeight: messageHeaderFontWeight,
color: messageHeaderTextColor,
),
);
}
@ -514,9 +533,12 @@ class ChatMessage extends StatelessWidget {
badgeScale: chatStore.settings.badgeScale,
launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType,
forceTimestamp: forceTimestamp,
showHistoricalTimestamps:
chatStore.settings.showHistoricalTimestamps,
channelIdToUserTwitch:
overrideChannelIdToUserTwitch ??
chatStore.assetsStore.channelIdToUserTwitch,
chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId:
overrideCurrentChannelId ?? chatStore.channelId,
),
@ -589,6 +611,19 @@ class ChatMessage extends StatelessWidget {
child: dividedMessage,
);
final sourceChannelId =
ircMessage.tags['source-room-id'] ?? ircMessage.tags['room-id'];
final currentChannelId =
overrideCurrentChannelId ?? chatStore.channelId;
final shouldFade =
chatStore.settings.focusCurrentChannel &&
sourceChannelId != null &&
sourceChannelId != currentChannelId;
final fadedMessage = shouldFade
? Opacity(opacity: 0.55, child: coloredMessage)
: coloredMessage;
final finalMessage = InkWell(
onTap: () {
FocusScope.of(context).unfocus();
@ -597,7 +632,7 @@ class ChatMessage extends StatelessWidget {
}
},
onLongPress: () => onLongPressMessage(context, defaultTextStyle),
child: coloredMessage,
child: fadedMessage,
);
return finalMessage;

View file

@ -36,7 +36,6 @@ class ChatTabs extends StatelessWidget {
);
if (!added && context.mounted) {
// If not added, it means the channel already exists (switched to it)
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Channel already open, switched to it'),
@ -56,13 +55,8 @@ class ChatTabs extends StatelessWidget {
final showTabBar = chatTabsStore.showTabBar;
final showMerge = tabs.where((t) => t.isActivated).length >= 2;
// Calculate extra top padding for tab bar when visible
final tabBarHeight = showTabBar ? 48.0 : 0.0;
// Get the top inset from listPadding (e.g., for AppBar in chat-only mode)
final topInset = listPadding?.resolve(TextDirection.ltr).top ?? 0;
// Adjust list padding to account for tab bar
final adjustedPadding = listPadding != null
? listPadding!.add(EdgeInsets.only(top: tabBarHeight))
: EdgeInsets.only(top: tabBarHeight);
@ -72,27 +66,20 @@ class ChatTabs extends StatelessWidget {
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
// On Android back gesture, close overlays first before navigating back
// Android back gesture: dismiss overlays before navigating away.
final activeStore = chatTabsStore.activeChatStore;
// Priority 1: Close emote menu
if (activeStore.assetsStore.showEmoteMenu) {
activeStore.assetsStore.showEmoteMenu = false;
return;
}
// Priority 2: Unfocus keyboard
if (activeStore.textFieldFocusNode.hasFocus) {
activeStore.unfocusInput();
return;
}
// Priority 3: Navigate back
Navigator.of(context).pop();
},
child: Stack(
children: [
// Chat content: merged view or IndexedStack
Positioned.fill(
child: chatTabsStore.mergedMode
? Chat(
@ -104,7 +91,6 @@ class ChatTabs extends StatelessWidget {
: IndexedStack(
index: activeIndex,
children: tabs.map((tabInfo) {
// Show placeholder for non-activated tabs
if (tabInfo.chatStore == null) {
return const SizedBox.shrink();
}
@ -117,7 +103,6 @@ class ChatTabs extends StatelessWidget {
}).toList(),
),
),
// Tab bar (only visible when more than 1 tab)
if (showTabBar)
Positioned(
top: topInset,
@ -127,8 +112,8 @@ class ChatTabs extends StatelessWidget {
height: 48,
child: Stack(
children: [
// When merge button is visible, clips at its center
// so chips slide under its left half then disappear.
// Right-clip past the merge button so chips slide
// under its left half then disappear.
Positioned(
left: 0,
top: 0,
@ -158,7 +143,7 @@ class ChatTabs extends StatelessWidget {
padding: EdgeInsets.only(
right: index < tabs.length - 1 ? 4 : 0,
),
child: _buildTab(context, index),
child: Center(child: _buildTab(context, index)),
);
},
),
@ -168,7 +153,9 @@ class ChatTabs extends StatelessWidget {
right: 12,
top: 0,
bottom: 0,
child: Center(child: _buildMergeToggle(context)),
child: Center(
child: _buildMoreActionsMenu(context),
),
),
],
),
@ -181,22 +168,48 @@ class ChatTabs extends StatelessWidget {
);
}
Widget _buildMergeToggle(BuildContext context) {
final isMerged = chatTabsStore.mergedMode;
return IconButton.filledTonal(
icon: const Icon(Icons.call_merge, size: 18),
tooltip: isMerged ? 'Split chats' : 'Merge loaded chats',
visualDensity: VisualDensity.compact,
isSelected: isMerged,
style: IconButton.styleFrom(
minimumSize: const Size(42, 42),
backgroundColor: isMerged
? null
: Theme.of(context).colorScheme.surfaceContainerHighest,
),
onPressed: () {
HapticFeedback.selectionClick();
chatTabsStore.toggleMergedMode();
Widget _buildMoreActionsMenu(BuildContext context) {
final settings = chatTabsStore.activeChatStore.settings;
return _AnchoredPopupMenu(
itemsBuilder: (close) => [
Observer(
builder: (_) => CheckboxMenuButton(
value: chatTabsStore.mergedMode,
onChanged: (_) {
HapticFeedback.selectionClick();
chatTabsStore.toggleMergedMode();
},
child: const Text('Merge chats'),
),
),
Observer(
builder: (_) => CheckboxMenuButton(
value: settings.focusCurrentChannel,
onChanged: (newValue) {
HapticFeedback.selectionClick();
settings.focusCurrentChannel = newValue ?? false;
},
child: const Text('Focus current channel'),
),
),
],
anchorBuilder: (context, toggle) {
return IconButton.filledTonal(
icon: const Icon(Icons.more_vert, size: 18),
tooltip: 'Chat options',
visualDensity: VisualDensity.compact,
style: IconButton.styleFrom(
minimumSize: const Size(42, 42),
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
onPressed: () {
HapticFeedback.selectionClick();
toggle();
},
);
},
);
}
@ -204,85 +217,209 @@ class ChatTabs extends StatelessWidget {
Widget _buildTab(BuildContext context, int index) {
final tabInfo = chatTabsStore.tabs[index];
final isActive = index == chatTabsStore.activeTabIndex;
final isActivated = tabInfo.isActivated;
final isSecondary = !tabInfo.isPrimary;
final displayName = getReadableName(
tabInfo.displayName,
tabInfo.channelLogin,
);
final avatar = ProfilePicture(userLogin: tabInfo.channelLogin, radius: 12);
return Observer(
builder: (context) {
final theme = Theme.of(context);
final isActivated = tabInfo.isActivated;
final hasUnread = chatTabsStore.hasUnreadMessages(index);
final dimmed =
!isActivated || !(tabInfo.chatStore?.isConnected ?? false);
if (tabInfo.isPrimary) {
return InputChip(
avatar: avatar,
label: Text(displayName),
selected: isActive,
showCheckmark: false,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onPressed: () {
if (!isActive) {
HapticFeedback.selectionClick();
chatTabsStore.setActiveTab(index);
}
},
);
}
return MenuAnchor(
menuChildren: [
if (isActivated)
MenuItemButton(
leadingIcon: const Icon(Icons.power_off_rounded, size: 18),
child: const Text('Disconnect'),
onPressed: () {
HapticFeedback.lightImpact();
chatTabsStore.deactivateTab(index);
},
InputChip buildChip({VoidCallback? onDeleted}) => InputChip(
avatar: Badge(
smallSize: 8,
backgroundColor: theme.colorScheme.primary,
isLabelVisible: hasUnread,
child: Opacity(
opacity: dimmed ? 0.5 : 1.0,
child: ProfilePicture(
userLogin: tabInfo.channelLogin,
radius: 12,
),
),
),
MenuItemButton(
leadingIcon: Icon(
Icons.delete_outline_rounded,
size: 18,
color: Theme.of(context).colorScheme.error,
),
child: Text(
'Remove',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
onPressed: () {
HapticFeedback.lightImpact();
chatTabsStore.removeTab(index);
},
),
],
builder: (context, controller, child) {
return InputChip(
avatar: isActivated ? avatar : Opacity(opacity: 0.5, child: avatar),
label: Text(
displayName,
style: isActivated
? null
: TextStyle(
color: Theme.of(
context,
).textTheme.bodyMedium?.color?.withValues(alpha: 0.5),
),
style: dimmed
? TextStyle(
color: theme.textTheme.bodyMedium?.color?.withValues(
alpha: 0.5,
),
)
: null,
),
selected: isActive,
showCheckmark: false,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onPressed: () {
if (!isActive) {
HapticFeedback.selectionClick();
chatTabsStore.setActiveTab(index);
}
if (isActive) return;
HapticFeedback.selectionClick();
chatTabsStore.setActiveTab(index);
},
onDeleted: () => controller.open(),
deleteButtonTooltipMessage: 'Tab options',
onDeleted: onDeleted,
deleteButtonTooltipMessage: onDeleted != null ? 'Tab options' : null,
);
if (!isSecondary) return buildChip();
return _AnchoredPopupMenu(
itemsBuilder: (close) => [
if (isActivated)
MenuItemButton(
leadingIcon: const Icon(Icons.power_off_rounded, size: 18),
child: const Text('Disconnect'),
onPressed: () {
HapticFeedback.lightImpact();
close();
chatTabsStore.deactivateTab(index);
},
),
MenuItemButton(
leadingIcon: Icon(
Icons.delete_outline_rounded,
size: 18,
color: theme.colorScheme.error,
),
child: Text(
'Remove',
style: TextStyle(color: theme.colorScheme.error),
),
onPressed: () {
HapticFeedback.lightImpact();
close();
chatTabsStore.removeTab(index);
},
),
],
anchorBuilder: (context, toggle) => buildChip(onDeleted: toggle),
);
},
);
}
}
/// Lightweight popup menu anchored to a child widget. The menu's top-right
/// aligns to the anchor's bottom-right via [CompositedTransformFollower] and
/// fades + scales in from that corner. [MenuAnchor] doesn't expose an entry
/// animation hook in this Flutter version, so we drive one ourselves.
class _AnchoredPopupMenu extends StatefulWidget {
final Widget Function(BuildContext context, VoidCallback toggle)
anchorBuilder;
final List<Widget> Function(VoidCallback close) itemsBuilder;
const _AnchoredPopupMenu({
required this.anchorBuilder,
required this.itemsBuilder,
});
@override
State<_AnchoredPopupMenu> createState() => _AnchoredPopupMenuState();
}
class _AnchoredPopupMenuState extends State<_AnchoredPopupMenu>
with SingleTickerProviderStateMixin {
final _link = LayerLink();
final _portalCtrl = OverlayPortalController();
late final _anim =
AnimationController(
duration: const Duration(milliseconds: 150),
reverseDuration: const Duration(milliseconds: 100),
vsync: this,
)..addStatusListener((status) {
if (status == AnimationStatus.dismissed && _portalCtrl.isShowing) {
_portalCtrl.hide();
}
});
late final _curve = CurvedAnimation(parent: _anim, curve: Curves.easeOut);
late final _scale = Tween<double>(begin: 0.92, end: 1.0).animate(_curve);
@override
void dispose() {
_curve.dispose();
_anim.dispose();
super.dispose();
}
void _toggle() {
if (_portalCtrl.isShowing) {
_anim.reverse();
} else {
_portalCtrl.show();
_anim.forward();
}
}
void _close() {
if (_portalCtrl.isShowing) _anim.reverse();
}
@override
Widget build(BuildContext context) {
return CompositedTransformTarget(
link: _link,
child: OverlayPortal(
controller: _portalCtrl,
overlayChildBuilder: (overlayContext) {
final theme = Theme.of(overlayContext);
return Stack(
children: [
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _close,
),
),
CompositedTransformFollower(
link: _link,
targetAnchor: Alignment.bottomRight,
followerAnchor: Alignment.topRight,
offset: const Offset(0, 4),
showWhenUnlinked: false,
child: FadeTransition(
opacity: _anim,
child: ScaleTransition(
scale: _scale,
alignment: Alignment.topRight,
child: Material(
color: theme.colorScheme.surface,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
side: BorderSide(
color: theme.colorScheme.outlineVariant.withValues(
alpha: 0.5,
),
width: 0.5,
),
),
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: IntrinsicWidth(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: widget.itemsBuilder(_close),
),
),
),
),
),
),
),
],
);
},
child: widget.anchorBuilder(context, _toggle),
),
);
}
}

View file

@ -14,6 +14,7 @@ class ChatUserModal extends StatefulWidget {
final String username;
final String displayName;
final String userId;
final VoidCallback? onActivateSourceTab;
const ChatUserModal({
super.key,
@ -21,6 +22,7 @@ class ChatUserModal extends StatefulWidget {
required this.username,
required this.displayName,
required this.userId,
this.onActivateSourceTab,
});
@override
@ -61,6 +63,7 @@ class _ChatUserModalState extends State<ChatUserModal> {
IconButton(
tooltip: 'Reply',
onPressed: () {
widget.onActivateSourceTab?.call();
widget.chatStore.textController.text =
'@${widget.username} ';
Navigator.pop(context);
@ -115,6 +118,7 @@ class _ChatUserModalState extends State<ChatUserModal> {
ircMessage: userMessages[index],
chatStore: widget.chatStore,
isModal: true,
forceTimestamp: true,
),
),
),

View file

@ -61,7 +61,7 @@ class ReplyThread extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: Text(
'$replyName: $replyBody',
style: const TextStyle(fontWeight: FontWeight.w500),
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
Flexible(

View file

@ -0,0 +1,32 @@
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
/// Single owner of `SettingsStore.syncedChatDelay` writes from the video
/// player stores. Previously both stores poked the setting directly,
/// making settings an ad-hoc event bus between video and chat.
class ChatLatencySync {
ChatLatencySync({required this.settingsStore});
final SettingsStore settingsStore;
/// Reset the delay to zero on stream transitions (channel enter, stream
/// end, chat-only toggle, etc). No-op when auto-sync is disabled.
void reset() {
if (settingsStore.autoSyncChatDelay) {
settingsStore.syncedChatDelay = 0.0;
}
}
/// Push a fresh video-latency measurement. Only writes when drift from
/// the current value exceeds the tolerance, to avoid thrashing the chat
/// delay countdown on minor fluctuations.
void report(double latencySeconds) {
if (!settingsStore.autoSyncChatDelay) return;
final current = settingsStore.syncedChatDelay;
if (current == 0 ||
(latencySeconds - current).abs() >
VideoTimingConstants.chatSyncDriftToleranceSeconds) {
settingsStore.syncedChatDelay = latencySeconds;
}
}
}

View file

@ -0,0 +1,86 @@
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/models/channel.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
/// Snapshot of a channel's live state.
class StreamInfoResult {
const StreamInfoResult({this.stream, this.offlineChannel});
/// Non-null when the channel is currently live.
final StreamTwitch? stream;
/// Non-null when the channel is confirmed offline (channel metadata fetched
/// successfully but no live stream). Both fields null means the fetch
/// failed entirely (network error, etc.).
final Channel? offlineChannel;
}
/// Debounced, deduplicated Twitch stream-info fetcher shared by both video
/// stores. Each store still owns its observable `_streamInfo` /
/// `_offlineChannelInfo` fields and decides how to react to the result.
class StreamInfoPoller {
StreamInfoPoller({
required this.twitchApi,
required this.userLogin,
required this.userId,
});
final TwitchApi twitchApi;
final String userLogin;
final String userId;
DateTime? _lastUpdate;
Future<StreamInfoResult>? _inFlight;
/// Fetches the latest stream info, returning `null` if the call was
/// debounced (caller should skip updating state). Concurrent calls await
/// the in-flight request instead of issuing a new one.
Future<StreamInfoResult?> fetch({bool forceUpdate = false}) async {
final pending = _inFlight;
if (pending != null) return pending;
final now = DateTime.now();
if (!forceUpdate && _lastUpdate != null) {
if (now.difference(_lastUpdate!) <
VideoTimingConstants.streamInfoDebounce) {
return null;
}
}
_lastUpdate = now;
final request = _runFetch();
_inFlight = request;
try {
return await request;
} finally {
if (identical(_inFlight, request)) _inFlight = null;
}
}
Future<StreamInfoResult> _runFetch() async {
try {
final info = await twitchApi.getStream(userLogin: userLogin);
return StreamInfoResult(stream: info);
} catch (_) {
// Helix can transiently drop a live stream from /streams (or 500 on
// one endpoint while others succeed). A false "confirmed offline"
// tears down latency polling and chat delay on healthy playback, so
// require a second failed check before falling back to /channels.
try {
await Future<void>.delayed(
VideoTimingConstants.offlineConfirmationDelay,
);
final info = await twitchApi.getStream(userLogin: userLogin);
return StreamInfoResult(stream: info);
} catch (_) {
try {
final channel = await twitchApi.getChannel(userId: userId);
return StreamInfoResult(offlineChannel: channel);
} catch (_) {
return const StreamInfoResult();
}
}
}
}
}

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/screens/channel/chat/details/chat_users_list.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/video/cast_button.dart';
@ -88,7 +89,7 @@ class VideoOverlay extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionHeader(
'Stream quality',
'Quality',
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
isFirst: true,
),
@ -108,7 +109,7 @@ class VideoOverlay extends StatelessWidget {
videoStore.setStreamQuality(quality);
SharedPreferences.getInstance().then(
(prefs) => prefs.setString(
'last_stream_quality',
lastStreamQualityKey(videoStore.userLogin),
quality,
),
);
@ -343,33 +344,14 @@ class VideoOverlay extends StatelessWidget {
videoSettingsButton,
],
),
Center(
child: ValueListenableBuilder<CastState>(
valueListenable: StreamProxyBridge.castState,
builder: (context, castState, _) {
if (castState.isCasting) {
return CastStatusButton(
castState: castState,
color: surfaceColor,
shadows: [
Shadow(
offset: const Offset(0, 3),
blurRadius: 8,
color: Colors.black.withValues(alpha: 0.6),
),
],
);
}
return Tooltip(
message: videoStore.paused ? 'Play' : 'Pause',
preferBelow: false,
child: IconButton(
iconSize: 56,
icon: Icon(
videoStore.paused
? Icons.play_arrow_rounded
: Icons.pause_rounded,
if (!videoStore.loading)
Center(
child: ValueListenableBuilder<CastState>(
valueListenable: StreamProxyBridge.castState,
builder: (context, castState, _) {
if (castState.isCasting) {
return CastStatusButton(
castState: castState,
color: surfaceColor,
shadows: [
Shadow(
@ -378,13 +360,33 @@ class VideoOverlay extends StatelessWidget {
color: Colors.black.withValues(alpha: 0.6),
),
],
);
}
return Tooltip(
message: videoStore.paused ? 'Play' : 'Pause',
preferBelow: false,
child: IconButton(
iconSize: 56,
icon: Icon(
videoStore.paused
? Icons.play_arrow_rounded
: Icons.pause_rounded,
color: surfaceColor,
shadows: [
Shadow(
offset: const Offset(0, 3),
blurRadius: 8,
color: Colors.black.withValues(alpha: 0.6),
),
],
),
onPressed: videoStore.handlePausePlay,
),
onPressed: videoStore.handlePausePlay,
),
);
},
);
},
),
),
),
Align(
alignment: Alignment.bottomLeft,
child: Row(

View file

@ -5,8 +5,12 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/models/channel.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/channel/video/chat_latency_sync.dart';
import 'package:frosty/screens/channel/video/stream_info_poller.dart';
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/services/stream_proxy_bridge.dart';
@ -36,8 +40,17 @@ abstract class VideoStoreBase with Store {
/// The [SimplePip] instance used for initiating PiP on Android.
final pip = SimplePip();
late final ChatLatencySync _chatLatencySync = ChatLatencySync(
settingsStore: settingsStore,
);
late final StreamInfoPoller _streamInfoPoller = StreamInfoPoller(
twitchApi: twitchApi,
userLogin: userLogin,
userId: userId,
);
var _firstTimeSettingQuality = true;
var _highLatencyCount = 0;
/// Whether [initVideo] should run on the next [onPageFinished].
///
@ -71,7 +84,49 @@ abstract class VideoStoreBase with Store {
if (!settingsStore.autoSyncChatDelay) return;
if (StreamProxyBridge.castState.value.isCasting) return;
_syncChatDelayFromLatencyText(receivedLatency);
final numericPart = receivedLatency.replaceAll(
RegExp(r'[^0-9.]'),
'',
);
final latencyAsDouble = double.tryParse(numericPart);
if (latencyAsDouble != null &&
latencyAsDouble >=
VideoTimingConstants.highLatencyThresholdSeconds &&
!_paused &&
!_loading &&
_highLatencyCount <
VideoTimingConstants.maxHighLatencyRecoveries) {
_highLatencyCount++;
if (_highLatencyCount == 1) {
debugPrint(
'VideoStore: high latency (${latencyAsDouble.round()}s), seeking to live edge',
);
try {
videoWebViewController.runJavaScript('''
(function() {
const v = window._videoEl || document.querySelector("video");
if (v && v.buffered.length > 0) {
v.currentTime = v.buffered.end(v.buffered.length - 1);
}
})();
''');
} catch (e) {
debugPrint(e.toString());
}
} else {
debugPrint(
'VideoStore: latency still high (${latencyAsDouble.round()}s), refreshing',
);
handleRefresh(haptic: false);
}
return;
}
if (latencyAsDouble != null) {
_highLatencyCount = 0;
_chatLatencySync.report(latencyAsDouble);
}
},
)
..addJavaScriptChannel(
@ -88,7 +143,9 @@ abstract class VideoStoreBase with Store {
return;
}
final prefs = await SharedPreferences.getInstance();
final lastStreamQuality = prefs.getString('last_stream_quality');
final lastStreamQuality = prefs.getString(
lastStreamQualityKey(userLogin),
);
if (lastStreamQuality == null) {
await _updateCastContext();
return;
@ -108,6 +165,7 @@ abstract class VideoStoreBase with Store {
..addJavaScriptChannel(
'VideoPlaying',
onMessageReceived: (message) {
_loading = false;
_paused = false;
if (Platform.isAndroid) pip.setIsPlaying(true);
},
@ -148,12 +206,6 @@ abstract class VideoStoreBase with Store {
/// Timer for periodic JavaScript state cleanup to prevent memory accumulation.
Timer? _jsCleanupTimer;
/// Tracks the last time stream info was updated to prevent double refresh
DateTime? _lastStreamInfoUpdate;
/// Tracks an in-flight stream info request so refreshes can be deduplicated.
Future<void>? _streamInfoRequest;
/// Disposes the overlay reactions.
late final ReactionDisposer _disposeOverlayReaction;
@ -168,6 +220,9 @@ abstract class VideoStoreBase with Store {
/// Disposes the stream proxy settings reaction.
ReactionDisposer? _disposeStreamProxyReaction;
@readonly
var _loading = true;
/// If the video is currently paused.
///
/// Does not pause or play the video, only used for rendering state of the overlay.
@ -222,10 +277,7 @@ abstract class VideoStoreBase with Store {
required this.authStore,
required this.settingsStore,
}) {
// Reset chat delay to 0 if auto sync is already enabled to prevent starting with old values
if (settingsStore.autoSyncChatDelay) {
settingsStore.syncedChatDelay = 0.0;
}
_chatLatencySync.reset();
// Initialize the video webview params for Android to enable video autoplay.
if (videoWebViewController.platform is AndroidWebViewController) {
(videoWebViewController.platform as AndroidWebViewController)
@ -248,13 +300,7 @@ abstract class VideoStoreBase with Store {
await updateStreamQualities();
} else {
_showDefaultOverlay();
try {
videoWebViewController.runJavaScript(
'window._latencyTracker?.stop()',
);
} catch (e) {
debugPrint(e.toString());
}
_stopLatencyTracker();
}
});
@ -272,6 +318,7 @@ abstract class VideoStoreBase with Store {
_stopJsCleanupTimer();
// Ensure overlay timer is active for clean UI
_scheduleOverlayHide();
_stopLatencyTracker();
}
});
@ -314,8 +361,7 @@ abstract class VideoStoreBase with Store {
(_) => (settingsStore.showLatency, settingsStore.autoSyncChatDelay),
(values) async {
final (showLatency, autoSync) = values;
// Only act if overlay is enabled (latency tracker only works with custom overlay)
if (!settingsStore.showOverlay) return;
if (!settingsStore.showVideo || !settingsStore.showOverlay) return;
if (showLatency || autoSync) {
// Start tracker if either setting is now enabled
@ -332,13 +378,7 @@ abstract class VideoStoreBase with Store {
}
} else {
// Stop tracker if both settings are now disabled
try {
videoWebViewController.runJavaScript(
'window._latencyTracker?.stop()',
);
} catch (e) {
debugPrint(e.toString());
}
_stopLatencyTracker();
}
},
);
@ -385,8 +425,9 @@ abstract class VideoStoreBase with Store {
final castLatency = castState.latency;
if (castState.isCasting) {
if (castLatency != null) {
settingsStore.syncedChatDelay = (castLatency.inMilliseconds / 1000)
.ceilToDouble();
_chatLatencySync.report(
(castLatency.inMilliseconds / 1000).ceilToDouble(),
);
}
return;
}
@ -403,7 +444,7 @@ abstract class VideoStoreBase with Store {
final latencyAsDouble = double.tryParse(numericPart);
if (latencyAsDouble != null) {
settingsStore.syncedChatDelay = latencyAsDouble;
_chatLatencySync.report(latencyAsDouble);
}
}
@ -741,7 +782,7 @@ abstract class VideoStoreBase with Store {
// Convert to abbreviated unit: "4.69 sec." -> "4.69s"
const match = latencyText.match(/([0-9.]+)\s*sec/i);
if (match) {
latencyText = parseFloat(match[1]).toFixed(2) + 's';
latencyText = Math.round(parseFloat(match[1])) + 's';
this.hasInitialLatency = true;
}
@ -987,7 +1028,7 @@ abstract class VideoStoreBase with Store {
// Only start if not already active
if (_streamInfoTimer?.isActive != true) {
_streamInfoTimer = Timer.periodic(
const Duration(seconds: 60),
VideoTimingConstants.webviewStreamInfoInterval,
(_) => updateStreamInfo(),
);
}
@ -1005,7 +1046,7 @@ abstract class VideoStoreBase with Store {
if (_jsCleanupTimer?.isActive == true) return;
_jsCleanupTimer = Timer.periodic(
const Duration(minutes: 10),
VideoTimingConstants.jsCleanupInterval,
(_) => _performJsSoftReset(),
);
}
@ -1015,7 +1056,9 @@ abstract class VideoStoreBase with Store {
_jsCleanupTimer = null;
}
void _scheduleOverlayHide([Duration delay = const Duration(seconds: 5)]) {
void _scheduleOverlayHide([
Duration delay = VideoTimingConstants.overlayAutoHide,
]) {
_overlayTimer?.cancel();
if (_isInPipMode) {
@ -1049,58 +1092,25 @@ abstract class VideoStoreBase with Store {
/// Set [forceUpdate] to true to bypass the rate limiting check.
@action
Future<void> updateStreamInfo({bool forceUpdate = false}) async {
if (_streamInfoRequest != null) {
await _streamInfoRequest;
return;
}
// Rate limiting: prevent too frequent updates unless forced
final now = DateTime.now();
if (!forceUpdate && _lastStreamInfoUpdate != null) {
final timeSinceLastUpdate = now.difference(_lastStreamInfoUpdate!);
if (timeSinceLastUpdate.inSeconds < 5) {
return; // Skip update if less than 5 seconds since last update
final result = await _streamInfoPoller.fetch(forceUpdate: forceUpdate);
if (result == null) return;
runInAction(() {
if (result.stream != null) {
_streamInfo = result.stream;
_offlineChannelInfo = null;
return;
}
}
_lastStreamInfoUpdate = now;
final request = _updateStreamInfoInternal();
_streamInfoRequest = request;
try {
await request;
} finally {
if (identical(_streamInfoRequest, request)) {
_streamInfoRequest = null;
}
}
}
Future<void> _updateStreamInfoInternal() async {
try {
_streamInfo = await twitchApi.getStream(userLogin: userLogin);
// Clear offline info when stream is live
_offlineChannelInfo = null;
} catch (e) {
_overlayTimer?.cancel();
_loading = false;
_streamInfo = null;
_paused = true;
// Try to fetch offline channel information
try {
_offlineChannelInfo = await twitchApi.getChannel(userId: userId);
} catch (channelError) {
_offlineChannelInfo = null;
}
_offlineChannelInfo = result.offlineChannel;
// Restart overlay timer in chat-only mode even on error
if (!settingsStore.showVideo) {
_scheduleOverlayHide();
}
} finally {
await _updateCastContext();
}
});
await _updateCastContext();
}
/// Handles the toggle overlay options.
@ -1115,7 +1125,7 @@ abstract class VideoStoreBase with Store {
if (settingsStore.showOverlay) {
_overlayVisible = true;
_scheduleOverlayHide(const Duration(seconds: 3));
_scheduleOverlayHide(VideoTimingConstants.overlayQuickHide);
}
}
@ -1131,7 +1141,9 @@ abstract class VideoStoreBase with Store {
HapticFeedback.lightImpact();
}
_paused = true;
_loading = true;
_firstTimeSettingQuality = true;
_highLatencyCount = 0;
_isInPipMode = false;
_latency = null;
_availableStreamQualities = [];
@ -1139,11 +1151,7 @@ abstract class VideoStoreBase with Store {
// Signal that initVideo() should run on the next onPageFinished
_needsInit = true;
try {
videoWebViewController.runJavaScript('window._latencyTracker?.stop()');
} catch (e) {
// Ignore - may not exist yet
}
_stopLatencyTracker();
await _syncStreamProxyBridge(refresh: false);
await videoWebViewController.loadRequest(Uri.parse(videoUrl));
@ -1231,10 +1239,14 @@ abstract class VideoStoreBase with Store {
// Explicitly stop latency tracker as defense-in-depth.
// The Video widget also loads about:blank during disposal which clears
// all JavaScript state, but this provides an extra safety layer.
_stopLatencyTracker();
}
void _stopLatencyTracker() {
try {
videoWebViewController.runJavaScript('window._latencyTracker?.stop()');
} catch (e) {
// Ignore - page may already be unloading
debugPrint(e.toString());
}
}
}

View file

@ -9,6 +9,26 @@ part of 'video_store.dart';
// 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,

View file

@ -0,0 +1,31 @@
/// Named durations and counts used by the video player stores.
/// Central owner so tuning values don't have to be hunted across files.
class VideoTimingConstants {
VideoTimingConstants._();
// Latency / chat sync
static const int highLatencyThresholdSeconds = 30;
static const int chatSyncDriftToleranceSeconds = 2;
/// Maximum consecutive high-latency interventions (seek/refresh) before
/// giving up on latency-driven recovery. Persistent high readings usually
/// mean device clock skew or a device that can't keep up; looping
/// recoveries forever makes playback worse, not better.
static const int maxHighLatencyRecoveries = 3;
/// Delay before re-checking /streams when the first check reports offline.
/// Helix transiently drops live streams from results; a single failed
/// check isn't enough to tear down healthy playback.
static const offlineConfirmationDelay = Duration(seconds: 2);
// Overlay
static const overlayAutoHide = Duration(seconds: 5);
static const overlayQuickHide = Duration(seconds: 3);
// Stream info
static const streamInfoDebounce = Duration(seconds: 5);
static const webviewStreamInfoInterval = Duration(minutes: 1);
// WebView housekeeping
static const jsCleanupInterval = Duration(minutes: 10);
}

View file

@ -166,13 +166,13 @@ class _HomeState extends State<Home> {
NavigationDestination(
icon: Icon(
Icons.favorite_border_rounded,
color: _homeStore.selectedIndex == 0
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant,
color: theme.colorScheme.onSurfaceVariant.withValues(
alpha: 0.5,
),
),
selectedIcon: Icon(
Icons.favorite_rounded,
color: theme.colorScheme.onSurface,
color: theme.colorScheme.primary,
),
label: 'Following',
tooltip: 'Following',
@ -180,15 +180,13 @@ class _HomeState extends State<Home> {
NavigationDestination(
icon: Icon(
Icons.arrow_upward_rounded,
color:
_homeStore.selectedIndex ==
(_authStore.isLoggedIn ? 1 : 0)
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant,
color: theme.colorScheme.onSurfaceVariant.withValues(
alpha: 0.5,
),
),
selectedIcon: Icon(
Icons.arrow_upward_rounded,
color: theme.colorScheme.onSurface,
color: theme.colorScheme.primary,
),
label: 'Top',
tooltip: 'Top',
@ -196,15 +194,13 @@ class _HomeState extends State<Home> {
NavigationDestination(
icon: Icon(
Icons.search_rounded,
color:
_homeStore.selectedIndex ==
(_authStore.isLoggedIn ? 2 : 1)
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant,
color: theme.colorScheme.onSurfaceVariant.withValues(
alpha: 0.5,
),
),
selectedIcon: Icon(
Icons.search_rounded,
color: theme.colorScheme.onSurface,
color: theme.colorScheme.primary,
),
label: 'Search',
tooltip: 'Search',

View file

@ -13,26 +13,63 @@ 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 StatefulWidget {
class ChatSettings extends StatelessWidget {
final SettingsStore settingsStore;
const ChatSettings({super.key, required this.settingsStore});
@override
State<ChatSettings> createState() => _ChatSettingsState();
}
class _ChatSettingsState extends State<ChatSettings> {
var showPreview = false;
@override
Widget build(BuildContext context) {
final settingsStore = widget.settingsStore;
return Observer(
builder: (context) => SettingsPageLayout(
children: [
const SectionHeader('Message sizing', isFirst: true),
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: [
@ -153,21 +190,41 @@ class _ChatSettingsState extends State<ChatSettings> {
settingsStore.showChatMessageDividers = newValue,
),
SettingsListSelect(
title: 'Message timestamps',
title: 'Timestamps',
selectedOption: timestampNames[settingsStore.timestampType.index],
options: timestampNames,
onChanged: (newValue) => settingsStore.timestampType =
TimestampType.values[timestampNames.indexOf(newValue)],
),
const SectionHeader('Delay and latency'),
SettingsListSwitch(
title: 'Sync message delay and stream latency',
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: 'Message delay',
title: 'Chat delay',
trailing: '${settingsStore.chatDelay.toInt()} seconds',
subtitle: 'Adds a delay before each message is rendered in chat.',
value: settingsStore.chatDelay,
@ -177,7 +234,7 @@ class _ChatSettingsState extends State<ChatSettings> {
),
const SectionHeader('Alerts'),
SettingsListSwitch(
title: 'Highlight first time chatters',
title: 'Highlight first-time chatters',
value: settingsStore.highlightFirstTimeChatter,
onChanged: (newValue) =>
settingsStore.highlightFirstTimeChatter = newValue,
@ -185,25 +242,25 @@ class _ChatSettingsState extends State<ChatSettings> {
SettingsListSwitch(
title: 'Show notices',
subtitle: const Text(
'Shows notices such as subs and re-subs, announcements, and raids.',
'Shows notices such as subs, announcements, and raids.',
),
value: settingsStore.showUserNotices,
onChanged: (newValue) => settingsStore.showUserNotices = newValue,
),
const SectionHeader('Layout'),
SettingsListSwitch(
title: 'Move emote menu button left',
title: 'Emote menu on left',
subtitle: const Text(
'Places the emote menu button on the left side to avoid accidental presses.',
'Places the emote menu button on the left side.',
),
value: settingsStore.emoteMenuButtonOnLeft,
onChanged: (newValue) =>
settingsStore.emoteMenuButtonOnLeft = newValue,
),
SettingsListSwitch(
title: 'Persist chat tabs',
title: 'Remember chat tabs',
subtitle: const Text(
'Secondary chat tabs are remembered when switching channels.',
'Secondary chat tabs are kept when switching channels.',
),
value: settingsStore.persistChatTabs,
onChanged: (newValue) {
@ -213,26 +270,23 @@ class _ChatSettingsState extends State<ChatSettings> {
}
},
),
const SectionHeader('Landscape mode'),
const SectionHeader('Landscape'),
SettingsListSwitch(
title: 'Move chat left',
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 other larger displays.',
),
subtitle: const Text('Intended for tablets and larger displays.'),
value: settingsStore.landscapeForceVerticalChat,
onChanged: (newValue) =>
settingsStore.landscapeForceVerticalChat = newValue,
),
SettingsListSelect(
title: 'Fill notch side',
subtitle:
'Overrides and fills the available space in devices with a display notch.',
title: 'Notch fill',
subtitle: 'Fills the display cutout area on the selected side.',
selectedOption:
landscapeCutoutNames[settingsStore.landscapeCutout.index],
options: landscapeCutoutNames,
@ -242,17 +296,16 @@ class _ChatSettingsState extends State<ChatSettings> {
)],
),
SettingsListSlider(
title: 'Chat overlay opacity',
title: 'Overlay chat opacity',
trailing:
'${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%',
subtitle:
'Sets the opacity (transparency) of the overlay chat in fullscreen mode.',
subtitle: 'Opacity of the chat overlay in fullscreen mode.',
value: settingsStore.fullScreenChatOverlayOpacity,
divisions: 10,
onChanged: (newValue) =>
settingsStore.fullScreenChatOverlayOpacity = newValue,
),
const SectionHeader('Muted keywords'),
const SectionHeader('Filtering'),
SettingsMutedWords(settingsStore: settingsStore),
SettingsListSwitch(
title: 'Match whole words',
@ -262,81 +315,42 @@ class _ChatSettingsState extends State<ChatSettings> {
value: settingsStore.matchWholeWord,
onChanged: (newValue) => settingsStore.matchWholeWord = newValue,
),
const SectionHeader('Autocomplete'),
SettingsListSwitch(
title: 'Show autocomplete bar',
subtitle: const Text(
'Shows a bar containing matching emotes and mentions while typing.',
),
value: settingsStore.autocomplete,
onChanged: (newValue) => settingsStore.autocomplete = newValue,
),
const SectionHeader('Emotes and badges'),
SettingsListSwitch(
title: 'Show Twitch emotes',
title: 'Twitch emotes',
value: settingsStore.showTwitchEmotes,
onChanged: (newValue) => settingsStore.showTwitchEmotes = newValue,
),
SettingsListSwitch(
title: 'Show Twitch badges',
title: 'Twitch badges',
value: settingsStore.showTwitchBadges,
onChanged: (newValue) => settingsStore.showTwitchBadges = newValue,
),
SettingsListSwitch(
title: 'Show 7TV emotes',
title: '7TV emotes',
value: settingsStore.show7TVEmotes,
onChanged: (newValue) => settingsStore.show7TVEmotes = newValue,
),
SettingsListSwitch(
title: 'Show BTTV emotes',
title: 'BTTV emotes',
value: settingsStore.showBTTVEmotes,
onChanged: (newValue) => settingsStore.showBTTVEmotes = newValue,
),
SettingsListSwitch(
title: 'Show BTTV badges',
title: 'BTTV badges',
value: settingsStore.showBTTVBadges,
onChanged: (newValue) => settingsStore.showBTTVBadges = newValue,
),
SettingsListSwitch(
title: 'Show FFZ emotes',
title: 'FFZ emotes',
value: settingsStore.showFFZEmotes,
onChanged: (newValue) => settingsStore.showFFZEmotes = newValue,
),
SettingsListSwitch(
title: 'Show FFZ badges',
title: 'FFZ badges',
value: settingsStore.showFFZBadges,
onChanged: (newValue) => settingsStore.showFFZBadges = newValue,
),
const SectionHeader('Recent messages'),
SettingsListSwitch(
title: 'Show historical 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,
),
],
),
);

View file

@ -25,12 +25,12 @@ class GeneralSettings extends StatelessWidget {
AccentColorSetting(settingsStore: settingsStore),
const SectionHeader('Stream card'),
SettingsListSwitch(
title: 'Use large stream card',
title: 'Large stream cards',
value: settingsStore.largeStreamCard,
onChanged: (newValue) => settingsStore.largeStreamCard = newValue,
),
SettingsListSwitch(
title: 'Show thumbnail',
title: 'Show thumbnails',
value: settingsStore.showThumbnails,
onChanged: (newValue) => settingsStore.showThumbnails = newValue,
),

View file

@ -148,6 +148,8 @@ abstract class _SettingsStoreBase with Store {
static const defaultShowDeletedMessages = false;
static const defaultShowChatMessageDividers = false;
static const defaultTimestampType = TimestampType.disabled;
static const defaultShowHistoricalTimestamps = false;
static const defaultFocusCurrentChannel = false;
// Delay defaults
static const defaultAutoSyncChatDelay = false;
@ -227,6 +229,14 @@ abstract class _SettingsStoreBase with Store {
@observable
var timestampType = defaultTimestampType;
@JsonKey(defaultValue: defaultShowHistoricalTimestamps)
@observable
var showHistoricalTimestamps = defaultShowHistoricalTimestamps;
@JsonKey(defaultValue: defaultFocusCurrentChannel)
@observable
var focusCurrentChannel = defaultFocusCurrentChannel;
// Delay options
@JsonKey(defaultValue: defaultAutoSyncChatDelay)
@observable
@ -346,6 +356,8 @@ abstract class _SettingsStoreBase with Store {
showDeletedMessages = defaultShowDeletedMessages;
showChatMessageDividers = defaultShowChatMessageDividers;
timestampType = defaultTimestampType;
showHistoricalTimestamps = defaultShowHistoricalTimestamps;
focusCurrentChannel = defaultFocusCurrentChannel;
autoSyncChatDelay = defaultAutoSyncChatDelay;
chatDelay = defaultChatDelay;

View file

@ -58,6 +58,9 @@ SettingsStore _$SettingsStoreFromJson(
unknownValue: TimestampType.disabled,
) ??
TimestampType.disabled
..showHistoricalTimestamps =
json['showHistoricalTimestamps'] as bool? ?? false
..focusCurrentChannel = json['focusCurrentChannel'] as bool? ?? false
..autoSyncChatDelay = json['autoSyncChatDelay'] as bool? ?? false
..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0
..highlightFirstTimeChatter =
@ -131,6 +134,8 @@ Map<String, dynamic> _$SettingsStoreToJson(
'showDeletedMessages': instance.showDeletedMessages,
'showChatMessageDividers': instance.showChatMessageDividers,
'timestampType': _$TimestampTypeEnumMap[instance.timestampType]!,
'showHistoricalTimestamps': instance.showHistoricalTimestamps,
'focusCurrentChannel': instance.focusCurrentChannel,
'autoSyncChatDelay': instance.autoSyncChatDelay,
'chatDelay': instance.chatDelay,
'highlightFirstTimeChatter': instance.highlightFirstTimeChatter,
@ -625,6 +630,46 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
late final _$showHistoricalTimestampsAtom = Atom(
name: '_SettingsStoreBase.showHistoricalTimestamps',
context: context,
);
@override
bool get showHistoricalTimestamps {
_$showHistoricalTimestampsAtom.reportRead();
return super.showHistoricalTimestamps;
}
@override
set showHistoricalTimestamps(bool value) {
_$showHistoricalTimestampsAtom.reportWrite(
value,
super.showHistoricalTimestamps,
() {
super.showHistoricalTimestamps = value;
},
);
}
late final _$focusCurrentChannelAtom = Atom(
name: '_SettingsStoreBase.focusCurrentChannel',
context: context,
);
@override
bool get focusCurrentChannel {
_$focusCurrentChannelAtom.reportRead();
return super.focusCurrentChannel;
}
@override
set focusCurrentChannel(bool value) {
_$focusCurrentChannelAtom.reportWrite(value, super.focusCurrentChannel, () {
super.focusCurrentChannel = value;
});
}
late final _$autoSyncChatDelayAtom = Atom(
name: '_SettingsStoreBase.autoSyncChatDelay',
context: context,
@ -1226,6 +1271,8 @@ fontSize: ${fontSize},
showDeletedMessages: ${showDeletedMessages},
showChatMessageDividers: ${showChatMessageDividers},
timestampType: ${timestampType},
showHistoricalTimestamps: ${showHistoricalTimestamps},
focusCurrentChannel: ${focusCurrentChannel},
autoSyncChatDelay: ${autoSyncChatDelay},
chatDelay: ${chatDelay},
syncedChatDelay: ${syncedChatDelay},

View file

@ -20,7 +20,7 @@ class VideoSettings extends StatelessWidget {
children: [
const SectionHeader('Player', isFirst: true),
SettingsListSwitch(
title: 'Enable video',
title: 'Show video player',
value: settingsStore.showVideo,
onChanged: (newValue) => settingsStore.showVideo = newValue,
),
@ -39,14 +39,6 @@ class VideoSettings extends StatelessWidget {
onChanged: (newValue) =>
settingsStore.useTextureRendering = newValue,
),
SettingsListSwitch(
title: 'Keep screen awake',
subtitle: const Text(
'Prevents the screen from sleeping while a channel is open.',
),
value: settingsStore.keepScreenAwake,
onChanged: (newValue) => settingsStore.keepScreenAwake = newValue,
),
SettingsListSelect(
title: 'Stream proxy mode',
selectedOption:
@ -103,9 +95,9 @@ class VideoSettings extends StatelessWidget {
onChanged: (newValue) => settingsStore.showOverlay = newValue,
),
SettingsListSwitch(
title: 'Long-press player to toggle overlay',
title: 'Toggle overlay on long-press',
subtitle: const Text(
'Allows switching between Twitch\'s overlay and the custom overlay.',
'Switch between Twitch\'s overlay and the custom overlay.',
),
value: settingsStore.toggleableOverlay,
onChanged: (newValue) => settingsStore.toggleableOverlay = newValue,

View file

@ -37,23 +37,23 @@ class FrostyColors extends ThemeExtension<FrostyColors> {
required this.overlayOnSurface,
});
static const light = FrostyColors(
highlightedMessage: Color(0xff9146ff),
emoteOnly: Color(0xFFFFB74D),
followersOnly: Color(0xFFF44336),
slowMode: Color(0xFF2196F3),
subOnly: Color(0xFF4CAF50),
uniqueMode: Color(0xFFAB47BC),
static FrostyColors light({required ColorScheme colorScheme}) => FrostyColors(
highlightedMessage: colorScheme.primary,
emoteOnly: const Color(0xFFFFB74D),
followersOnly: const Color(0xFFF44336),
slowMode: const Color(0xFF2196F3),
subOnly: const Color(0xFF4CAF50),
uniqueMode: const Color(0xFFAB47BC),
overlayOnSurface: Colors.white,
);
static const dark = FrostyColors(
highlightedMessage: Color(0xff9146ff),
emoteOnly: Color(0xFFFFCC80),
followersOnly: Color(0xFFE57373),
slowMode: Color(0xFF64B5F6),
subOnly: Color(0xFF81C784),
uniqueMode: Color(0xFFBA68C8),
static FrostyColors dark({required ColorScheme colorScheme}) => FrostyColors(
highlightedMessage: colorScheme.primary,
emoteOnly: const Color(0xFFFFCC80),
followersOnly: const Color(0xFFE57373),
slowMode: const Color(0xFF64B5F6),
subOnly: const Color(0xFF81C784),
uniqueMode: const Color(0xFFBA68C8),
overlayOnSurface: Colors.white,
);
@ -130,11 +130,16 @@ class FrostyThemes {
return ThemeData(
extensions: [
colorScheme.brightness == Brightness.dark
? FrostyColors.dark
: FrostyColors.light,
? FrostyColors.dark(colorScheme: colorScheme)
: FrostyColors.light(colorScheme: colorScheme),
],
colorScheme: colorScheme,
fontFamily: 'Inter',
textSelectionTheme: TextSelectionThemeData(
cursorColor: colorScheme.primary,
selectionColor: colorScheme.primary.withValues(alpha: 0.3),
selectionHandleColor: colorScheme.primary,
),
scaffoldBackgroundColor: backgroundColor,
bottomSheetTheme: BottomSheetThemeData(
showDragHandle: true,
@ -144,7 +149,7 @@ class FrostyThemes {
menuTheme: MenuThemeData(
style: MenuStyle(
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(vertical: 4),
EdgeInsets.symmetric(vertical: 2),
),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(
@ -155,7 +160,12 @@ class FrostyThemes {
),
),
menuButtonTheme: MenuButtonThemeData(
style: MenuItemButton.styleFrom(visualDensity: VisualDensity.compact),
style: MenuItemButton.styleFrom(
visualDensity: VisualDensity.compact,
minimumSize: const Size(0, 40),
padding: const EdgeInsets.symmetric(horizontal: 12),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
dialogTheme: DialogThemeData(
backgroundColor: backgroundColor,
@ -199,6 +209,23 @@ class FrostyThemes {
),
),
),
listTileTheme: ListTileThemeData(
titleTextStyle: TextStyle(
color: colorScheme.onSurface,
fontSize: 15,
fontWeight: FontWeight.w500,
letterSpacing: 0,
height: 1.5,
),
subtitleTextStyle: TextStyle(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
fontSize: 13,
height: 1.4,
),
leadingAndTrailingTextStyle: TextStyle(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
),
navigationBarTheme: NavigationBarThemeData(
elevation: 0,
backgroundColor: backgroundColor,
@ -241,46 +268,34 @@ class FrostyThemes {
headlineSmall: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: -0.019,
letterSpacing: 0,
),
// App bar title
titleLarge: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
letterSpacing: -0.019,
letterSpacing: 0,
),
// Section titles
titleMedium: TextStyle(
fontWeight: FontWeight.w600,
letterSpacing: -0.011,
),
titleMedium: TextStyle(fontWeight: FontWeight.w600, letterSpacing: 0),
// Tab bar title
titleSmall: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: -0.011,
letterSpacing: 0,
),
// Labels
labelLarge: TextStyle(
fontWeight: FontWeight.w500,
letterSpacing: -0.006,
),
labelLarge: TextStyle(fontWeight: FontWeight.w500, letterSpacing: 0),
labelMedium: TextStyle(fontWeight: FontWeight.w500, letterSpacing: 0),
labelSmall: TextStyle(
fontWeight: FontWeight.w500,
letterSpacing: 0.005,
),
labelSmall: TextStyle(fontWeight: FontWeight.w500, letterSpacing: 0),
// Body text
bodyLarge: TextStyle(
fontWeight: FontWeight.w500,
letterSpacing: -0.011,
),
bodyMedium: TextStyle(letterSpacing: -0.006),
bodyLarge: TextStyle(fontWeight: FontWeight.w500, letterSpacing: 0),
bodyMedium: TextStyle(letterSpacing: 0),
bodySmall: TextStyle(letterSpacing: 0),
),
);

View file

@ -13,17 +13,17 @@ import 'package:provider/provider.dart';
class OrientationUtils {
/// Gets the current orientation from the given context
static Orientation getCurrentOrientation(BuildContext context) {
return MediaQuery.of(context).orientation;
return MediaQuery.orientationOf(context);
}
/// Returns true if the current orientation is portrait
static bool isPortrait(BuildContext context) {
return MediaQuery.of(context).orientation == Orientation.portrait;
return MediaQuery.orientationOf(context) == Orientation.portrait;
}
/// Returns true if the current orientation is landscape
static bool isLandscape(BuildContext context) {
return MediaQuery.of(context).orientation == Orientation.landscape;
return MediaQuery.orientationOf(context) == Orientation.landscape;
}
}
@ -55,16 +55,16 @@ extension ContextExtensions on BuildContext {
// ===== SCREEN & LAYOUT =====
/// Gets the screen width
double get screenWidth => MediaQuery.of(this).size.width;
double get screenWidth => MediaQuery.sizeOf(this).width;
/// Gets the screen height
double get screenHeight => MediaQuery.of(this).size.height;
double get screenHeight => MediaQuery.sizeOf(this).height;
/// Gets the safe area padding top (commonly used)
double get safePaddingTop => MediaQuery.of(this).padding.top;
double get safePaddingTop => MediaQuery.paddingOf(this).top;
/// Gets the safe area padding bottom (commonly used)
double get safePaddingBottom => MediaQuery.of(this).padding.bottom;
double get safePaddingBottom => MediaQuery.paddingOf(this).bottom;
// ===== PROVIDER ACCESS =====

View file

@ -0,0 +1,122 @@
import 'package:extended_text_field/extended_text_field.dart';
import 'package:flutter/material.dart';
import 'package:frosty/models/emotes.dart';
import 'package:frosty/widgets/frosty_cached_network_image.dart';
/// A [SpecialTextSpanBuilder] that renders emote names as inline images.
///
/// This builder parses input text word-by-word and converts recognized emote
/// names into [ExtendedWidgetSpan] widgets displaying the emote image.
/// Unrecognized words remain as regular text.
class EmoteTextSpanBuilder extends SpecialTextSpanBuilder {
/// Map of emote names to their [Emote] objects (global + channel emotes).
final Map<String, Emote> emoteToObject;
/// Map of user-owned emotes (subscriber, bits, unlocked).
final Map<String, Emote> userEmoteToObject;
/// The height to render emotes at.
final double emoteSize;
EmoteTextSpanBuilder({
required this.emoteToObject,
required this.userEmoteToObject,
required this.emoteSize,
});
@override
TextSpan build(
String data, {
TextStyle? textStyle,
SpecialTextGestureTapCallback? onTap,
}) {
if (data.isEmpty) {
return TextSpan(text: '', style: textStyle);
}
final spans = <InlineSpan>[];
var currentIndex = 0;
// Split by spaces but preserve space positions
final parts = _splitPreservingSpaces(data);
for (final part in parts) {
if (part == ' ') {
// Add space as regular text
spans.add(TextSpan(text: ' ', style: textStyle));
currentIndex += 1;
} else if (part.isNotEmpty) {
// Check if this word is an emote - render immediately on exact match
final emote = emoteToObject[part] ?? userEmoteToObject[part];
if (emote != null) {
spans.add(_createEmoteSpan(emote, part, currentIndex));
} else {
spans.add(TextSpan(text: part, style: textStyle));
}
currentIndex += part.length;
}
}
return TextSpan(children: spans, style: textStyle);
}
/// Splits text into words and spaces, preserving the spaces as separate elements.
List<String> _splitPreservingSpaces(String text) {
final result = <String>[];
final buffer = StringBuffer();
for (var i = 0; i < text.length; i++) {
final char = text[i];
if (char == ' ') {
if (buffer.isNotEmpty) {
result.add(buffer.toString());
buffer.clear();
}
result.add(' ');
} else {
buffer.write(char);
}
}
if (buffer.isNotEmpty) {
result.add(buffer.toString());
}
return result;
}
/// Creates an [ExtendedWidgetSpan] for the given emote.
ExtendedWidgetSpan _createEmoteSpan(Emote emote, String text, int start) {
// Calculate dimensions - use emote's native size if available,
// otherwise use the configured emoteSize
final height = emote.height?.toDouble() ?? emoteSize;
final width = emote.width?.toDouble();
return ExtendedWidgetSpan(
actualText: text,
start: start,
alignment: PlaceholderAlignment.middle,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 1),
child: FrostyCachedNetworkImage(
imageUrl: emote.url,
height: height,
width: width,
useFade: false,
),
),
);
}
@override
SpecialText? createSpecialText(
String flag, {
TextStyle? textStyle,
SpecialTextGestureTapCallback? onTap,
int? index,
}) {
// We handle all parsing in the build method, so this returns null
return null;
}
}

View file

@ -49,10 +49,10 @@ class ExpandableSectionHeader extends StatelessWidget {
child: Text(
text.toUpperCase(),
style: TextStyle(
fontSize: fontSize ?? 13,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
color: context.colorScheme.onSurface.withValues(
fontSize: fontSize ?? 12,
fontWeight: FontWeight.w500,
letterSpacing: 0,
color: context.colorScheme.onSurfaceVariant.withValues(
alpha: 0.6,
),
),
@ -63,7 +63,9 @@ class ExpandableSectionHeader extends StatelessWidget {
duration: const Duration(milliseconds: 200),
child: Icon(
Icons.keyboard_arrow_down,
color: context.colorScheme.onSurfaceVariant,
color: context.colorScheme.onSurfaceVariant.withValues(
alpha: 0.6,
),
size: 20,
),
),

View file

@ -138,57 +138,61 @@ class _LiveIndicatorState extends State<LiveIndicator>
);
}
return AnimatedBuilder(
animation: _pingController!,
builder: (context, child) {
final pingSize = widget.size * _pingScale!.value;
return SizedBox(
width: widget.size,
height: widget.size,
child: Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
// Ping ring rendered outside without affecting layout
Positioned.fill(
child: IgnorePointer(
child: OverflowBox(
maxWidth: pingSize,
maxHeight: pingSize,
child: Center(
child: Opacity(
opacity: _pingOpacity!.value,
child: Container(
width: pingSize,
height: pingSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: widget.color.withValues(
alpha: _borderOpacity,
return SizedBox(
width: widget.size,
height: widget.size,
child: Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
// Ping ring in its own RepaintBoundary so its 60fps animation
// doesn't dirty the overlay's compositing layer (expensive with
// Impeller + PlatformView).
Positioned.fill(
child: RepaintBoundary(
child: AnimatedBuilder(
animation: _pingController!,
builder: (context, child) {
final pingSize = widget.size * _pingScale!.value;
return IgnorePointer(
child: OverflowBox(
maxWidth: pingSize,
maxHeight: pingSize,
child: Center(
child: Opacity(
opacity: _pingOpacity!.value,
child: Container(
width: pingSize,
height: pingSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: widget.color.withValues(
alpha: _borderOpacity,
),
width: _borderWidth,
),
width: _borderWidth,
),
),
),
),
),
),
),
);
},
),
// Static center dot
Container(
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
color: widget.color,
shape: BoxShape.circle,
),
),
],
),
),
);
},
// Static center dot
Container(
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
color: widget.color,
shape: BoxShape.circle,
),
),
],
),
);
}
}

View file

@ -0,0 +1,46 @@
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
/// Reports the rendered size of [child] via [onChange] whenever it changes.
///
/// Unlike `LayoutBuilder` (which exposes incoming constraints, not intrinsic
/// size), this measures the child's actual laid-out size. The callback is
/// deferred to a post-frame callback so listeners can safely mutate state
/// (e.g. a store observable) without triggering a build-phase mutation.
class MeasureSize extends SingleChildRenderObjectWidget {
const MeasureSize({
super.key,
required this.onChange,
required Widget super.child,
});
final ValueChanged<Size> onChange;
@override
RenderObject createRenderObject(BuildContext context) =>
_MeasureSizeRenderObject(onChange);
@override
void updateRenderObject(BuildContext context, RenderObject renderObject) {
(renderObject as _MeasureSizeRenderObject).onChange = onChange;
}
}
class _MeasureSizeRenderObject extends RenderProxyBox {
_MeasureSizeRenderObject(this.onChange);
ValueChanged<Size> onChange;
Size? _oldSize;
@override
void performLayout() {
super.performLayout();
final newSize = child?.size ?? Size.zero;
if (_oldSize == newSize) return;
_oldSize = newSize;
// Defer out of the layout phase so the callback can mutate observed state.
WidgetsBinding.instance.addPostFrameCallback((_) => onChange(newSize));
}
}

View file

@ -36,10 +36,12 @@ class SectionHeader extends StatelessWidget {
Text(
text.toUpperCase(),
style: TextStyle(
fontSize: fontSize ?? 13,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
color: context.colorScheme.onSurfaceVariant,
fontSize: fontSize ?? 12,
fontWeight: FontWeight.w500,
letterSpacing: 0,
color: context.colorScheme.onSurfaceVariant.withValues(
alpha: 0.6,
),
),
),
],

View file

@ -39,7 +39,7 @@ class _SettingsPageLayoutState extends State<SettingsPageLayout> {
// Responsive values based on orientation
final listPadding = EdgeInsets.only(
top: isPortrait ? 116 : 92,
top: isPortrait ? 124 : 100,
bottom: widget.hasBottomPadding ? context.safePaddingBottom + 8 : 0,
);

View file

@ -265,6 +265,23 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
extended_text_field:
dependency: "direct main"
description:
path: "."
ref: "76305e9b115f0b41dc0d23e7a4f651df454fea89"
resolved-ref: "76305e9b115f0b41dc0d23e7a4f651df454fea89"
url: "https://github.com/tommyxchow/extended_text_field.git"
source: git
version: "16.0.2"
extended_text_library:
dependency: transitive
description:
name: extended_text_library
sha256: "13d99f8a10ead472d5e2cf4770d3d047203fe5054b152e9eb5dc692a71befbba"
url: "https://pub.dev"
source: hosted
version: "12.0.1"
fake_async:
dependency: transitive
description:
@ -1274,18 +1291,18 @@ packages:
dependency: "direct main"
description:
name: webview_flutter
sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9
sha256: e4d3474277c5043f5f3100ed27d94ad693abfd5c602b0221c719a4497e4ba1e4
url: "https://pub.dev"
source: hosted
version: "4.13.1"
version: "4.14.0"
webview_flutter_android:
dependency: "direct main"
description:
name: webview_flutter_android
sha256: ad5182eff9a550925330cb9f0cb038eddfdd5712aba8b77aa0f0400e50f6e688
sha256: a97db7a44f8e71af2f3971c45550a08cce1fb60059c1b8e534251e6cfb753490
url: "https://pub.dev"
source: hosted
version: "4.12.0"
version: "4.13.0"
webview_flutter_platform_interface:
dependency: transitive
description:
@ -1298,10 +1315,10 @@ packages:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: "82648217f537573e1ca9ae9952d3eacedca6ab5aee69dc84445fc763766dcea2"
sha256: c879dd64b87c452aa84381b244d5469da57ba7e8cca6884c7b1e0d406372c12d
url: "https://pub.dev"
source: hosted
version: "3.25.1"
version: "3.26.0"
win32:
dependency: transitive
description:
@ -1335,5 +1352,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.10.3 <4.0.0"
flutter: ">=3.38.4"
dart: ">=3.12.0 <4.0.0"
flutter: ">=3.44.0"

View file

@ -14,7 +14,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 5.0.5+68
version: 5.1.0+69
environment:
sdk: ^3.9.0
@ -53,12 +53,18 @@ dependencies:
photo_view: ^0.15.0
flutter_markdown_plus: ^1.0.7
flutter_colorpicker: ^1.1.0
path_provider: ^2.1.5
dio: ^5.9.0
app_links: ^7.0.0
native_device_orientation: ^2.1.0
dev_dependencies:
path_provider: ^2.1.5
dio: ^5.9.0
app_links: ^7.0.0
# Fork pinned for Flutter 3.44 compatibility: upstream 16.0.2 references
# ExtendSelectionByPageIntent, removed in Flutter 3.44.
extended_text_field:
git:
url: https://github.com/tommyxchow/extended_text_field.git
ref: 76305e9b115f0b41dc0d23e7a4f651df454fea89
native_device_orientation: ^2.1.0
dev_dependencies:
flutter_test:
sdk: flutter

View file

@ -0,0 +1,111 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/screens/channel/video/chat_latency_sync.dart';
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
void main() {
// Reference the real constant so assertions track config changes rather
// than a hardcoded magic number.
final tolerance =
VideoTimingConstants.chatSyncDriftToleranceSeconds.toDouble();
late SettingsStore settingsStore;
late ChatLatencySync sync;
setUp(() {
settingsStore = SettingsStore.fromJson({});
sync = ChatLatencySync(settingsStore: settingsStore);
});
group('ChatLatencySync reset', () {
test('zeroes syncedChatDelay when autoSyncChatDelay is enabled', () {
settingsStore.autoSyncChatDelay = true;
settingsStore.syncedChatDelay = 7.5;
sync.reset();
expect(settingsStore.syncedChatDelay, 0.0);
});
test('is a no-op when autoSyncChatDelay is disabled', () {
settingsStore.autoSyncChatDelay = false;
settingsStore.syncedChatDelay = 7.5;
sync.reset();
expect(settingsStore.syncedChatDelay, 7.5);
});
});
group('ChatLatencySync report', () {
test('does nothing when autoSyncChatDelay is disabled', () {
settingsStore.autoSyncChatDelay = false;
settingsStore.syncedChatDelay = 0.0;
sync.report(12.0);
expect(settingsStore.syncedChatDelay, 0.0);
});
test('writes the first measurement when current delay is zero', () {
settingsStore.autoSyncChatDelay = true;
settingsStore.syncedChatDelay = 0.0;
// Even a tiny value writes because the current value is exactly 0.
sync.report(0.5);
expect(settingsStore.syncedChatDelay, 0.5);
});
test('writes when drift exceeds the tolerance', () {
settingsStore.autoSyncChatDelay = true;
settingsStore.syncedChatDelay = 5.0;
final report = 5.0 + tolerance + 1.0;
sync.report(report);
expect(settingsStore.syncedChatDelay, report);
});
test('does not write when drift is within the tolerance', () {
settingsStore.autoSyncChatDelay = true;
settingsStore.syncedChatDelay = 5.0;
// Strictly inside the tolerance band.
sync.report(5.0 + (tolerance - 0.5));
expect(settingsStore.syncedChatDelay, 5.0);
});
test('does not write when drift is exactly at the tolerance', () {
settingsStore.autoSyncChatDelay = true;
settingsStore.syncedChatDelay = 5.0;
// The code uses strictly-greater-than (`>`), so a drift equal to the
// tolerance must NOT write.
sync.report(5.0 + tolerance);
expect(settingsStore.syncedChatDelay, 5.0);
});
test('writes when drift just exceeds the tolerance', () {
settingsStore.autoSyncChatDelay = true;
settingsStore.syncedChatDelay = 5.0;
final report = 5.0 + tolerance + 0.1;
sync.report(report);
expect(settingsStore.syncedChatDelay, report);
});
test('writes when a downward drift exceeds the tolerance', () {
settingsStore.autoSyncChatDelay = true;
settingsStore.syncedChatDelay = 10.0;
final report = 10.0 - tolerance - 1.0;
sync.report(report);
expect(settingsStore.syncedChatDelay, report);
});
});
}

View file

@ -0,0 +1,194 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/models/channel.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/channel/video/stream_info_poller.dart';
import 'package:mocktail/mocktail.dart';
import '../../../fixtures/api_responses.dart';
class MockTwitchApi extends Mock implements TwitchApi {}
void main() {
const userLogin = 'testuser';
const userId = '12345';
late MockTwitchApi api;
// Fresh fixtures per test so nothing leaks between cases.
StreamTwitch buildStream() => StreamTwitch.fromJson(
(twitchStreamResponse['data']! as List).first as Map<String, dynamic>,
);
Channel buildChannel() => Channel.fromJson(
(twitchChannelResponse['data']! as List).first as Map<String, dynamic>,
);
// Fresh poller per test so _lastUpdate / _inFlight debounce state never
// carries over between tests.
StreamInfoPoller buildPoller() =>
StreamInfoPoller(twitchApi: api, userLogin: userLogin, userId: userId);
setUp(() {
api = MockTwitchApi();
});
group('live', () {
test('returns stream when getStream succeeds', () async {
when(
() => api.getStream(userLogin: any(named: 'userLogin')),
).thenAnswer((_) async => buildStream());
final poller = buildPoller();
final result = await poller.fetch();
expect(result, isNotNull);
expect(result!.stream, isNotNull);
expect(result.stream!.userName, 'TestUser');
expect(result.offlineChannel, isNull);
verify(() => api.getStream(userLogin: userLogin)).called(1);
verifyNever(() => api.getChannel(userId: any(named: 'userId')));
});
});
group('debounce', () {
test(
'second fetch within debounce window returns null, forceUpdate bypasses',
() async {
when(
() => api.getStream(userLogin: any(named: 'userLogin')),
).thenAnswer((_) async => buildStream());
final poller = buildPoller();
// First fetch resolves with a real result.
final first = await poller.fetch();
expect(first, isNotNull);
expect(first!.stream, isNotNull);
// Immediate second fetch (no forceUpdate) is inside the 5s debounce
// window -> debounced -> null.
final second = await poller.fetch();
expect(second, isNull);
// forceUpdate bypasses the debounce -> a fresh result.
final forced = await poller.fetch(forceUpdate: true);
expect(forced, isNotNull);
expect(forced!.stream, isNotNull);
// Two real fetches (first + forced); the debounced one issued no call.
verify(() => api.getStream(userLogin: userLogin)).called(2);
},
);
});
group('in-flight dedup', () {
test(
'concurrent fetches await the same request and call getStream once',
() async {
final completer = Completer<StreamTwitch>();
when(
() => api.getStream(userLogin: any(named: 'userLogin')),
).thenAnswer((_) => completer.future);
final poller = buildPoller();
// Fire both without awaiting so the second sees the in-flight future.
final firstFuture = poller.fetch();
final secondFuture = poller.fetch();
// Now let the underlying request complete.
completer.complete(buildStream());
final first = await firstFuture;
final second = await secondFuture;
expect(first, isNotNull);
expect(second, isNotNull);
// Same deduplicated result instance.
expect(identical(first, second), isTrue);
expect(first!.stream, isNotNull);
verify(() => api.getStream(userLogin: userLogin)).called(1);
},
);
});
group('second-chance retry', () {
test(
'getStream fails once then succeeds on retry -> stream, no getChannel',
() async {
var calls = 0;
when(() => api.getStream(userLogin: any(named: 'userLogin'))).thenAnswer(
(_) async {
calls++;
if (calls == 1) throw Exception('transient drop');
return buildStream();
},
);
final poller = buildPoller();
final result = await poller.fetch();
expect(result, isNotNull);
expect(result!.stream, isNotNull);
expect(result.offlineChannel, isNull);
verify(() => api.getStream(userLogin: userLogin)).called(2);
verifyNever(() => api.getChannel(userId: any(named: 'userId')));
},
);
});
group('offline', () {
test(
'getStream fails twice, getChannel succeeds -> offlineChannel',
() async {
when(
() => api.getStream(userLogin: any(named: 'userLogin')),
).thenThrow(Exception('offline'));
when(
() => api.getChannel(userId: any(named: 'userId')),
).thenAnswer((_) async => buildChannel());
final poller = buildPoller();
final result = await poller.fetch();
expect(result, isNotNull);
expect(result!.stream, isNull);
expect(result.offlineChannel, isNotNull);
expect(result.offlineChannel!.broadcasterName, 'TestUser');
verify(() => api.getStream(userLogin: userLogin)).called(2);
verify(() => api.getChannel(userId: userId)).called(1);
},
);
});
group('total failure', () {
test(
'getStream fails twice and getChannel fails -> empty result',
() async {
when(
() => api.getStream(userLogin: any(named: 'userLogin')),
).thenThrow(Exception('stream boom'));
when(
() => api.getChannel(userId: any(named: 'userId')),
).thenThrow(Exception('channel boom'));
final poller = buildPoller();
final result = await poller.fetch();
expect(result, isNotNull);
expect(result!.stream, isNull);
expect(result.offlineChannel, isNull);
verify(() => api.getStream(userLogin: userLogin)).called(2);
verify(() => api.getChannel(userId: userId)).called(1);
},
);
});
}

View file

@ -0,0 +1,77 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
/// Regression guard for timing relationships used by Glacier's WebView video
/// recovery and chat-sync logic.
void main() {
group('VideoTimingConstants latency vs chat-sync tolerance', () {
test('highLatencyThreshold is much greater than chatSyncDriftTolerance', () {
expect(
VideoTimingConstants.highLatencyThresholdSeconds,
greaterThan(VideoTimingConstants.chatSyncDriftToleranceSeconds),
reason:
'High-latency intervention must only trigger far past the chat-sync '
'drift tolerance, otherwise normal sync drift could trigger '
'playback recovery.',
);
});
test('highLatencyThreshold sits comfortably above normal live latency', () {
expect(
VideoTimingConstants.highLatencyThresholdSeconds,
greaterThanOrEqualTo(15),
reason:
'highLatencyThresholdSeconds must stay above normal live latency '
'so healthy playback is not flagged for recovery.',
);
});
test('maxHighLatencyRecoveries is positive and bounded', () {
expect(VideoTimingConstants.maxHighLatencyRecoveries, greaterThan(0));
expect(
VideoTimingConstants.maxHighLatencyRecoveries,
lessThanOrEqualTo(5),
);
});
});
group('VideoTimingConstants stream info', () {
test('polling and debounce intervals are positive', () {
expect(
VideoTimingConstants.streamInfoDebounce,
greaterThan(Duration.zero),
);
expect(
VideoTimingConstants.webviewStreamInfoInterval,
greaterThan(Duration.zero),
);
expect(
VideoTimingConstants.offlineConfirmationDelay,
greaterThan(Duration.zero),
);
});
test('offline confirmation is faster than periodic polling', () {
expect(
VideoTimingConstants.offlineConfirmationDelay,
lessThan(VideoTimingConstants.webviewStreamInfoInterval),
);
});
});
group('VideoTimingConstants overlay and cleanup', () {
test('overlayQuickHide is not slower than overlayAutoHide', () {
expect(
VideoTimingConstants.overlayQuickHide,
lessThanOrEqualTo(VideoTimingConstants.overlayAutoHide),
);
});
test('jsCleanupInterval is positive', () {
expect(
VideoTimingConstants.jsCleanupInterval,
greaterThan(Duration.zero),
);
});
});
}