diff --git a/.github/workflows/gh-release.yml b/.github/workflows/gh-release.yml index 19126d8f..644206bb 100644 --- a/.github/workflows/gh-release.yml +++ b/.github/workflows/gh-release.yml @@ -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 }} diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 72c85a8b..d5c4ebcd 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 = "../.." } diff --git a/android/gradle.properties b/android/gradle.properties index 102ab549..e21ae7c6 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -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 diff --git a/assets/release-notes.md b/assets/release-notes.md index a2c033ff..14739172 100644 --- a/assets/release-notes.md +++ b/assets/release-notes.md @@ -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 diff --git a/lib/apis/unauthorized_interceptor.dart b/lib/apis/unauthorized_interceptor.dart index 5f3f26cc..bd983fc4 100644 --- a/lib/apis/unauthorized_interceptor.dart +++ b/lib/apis/unauthorized_interceptor.dart @@ -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'); + } } diff --git a/lib/constants.dart b/lib/constants.dart index 2edaddba..5d98aedf 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -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'); diff --git a/lib/main.dart b/lib/main.dart index 51298115..bd97c0f8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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; diff --git a/lib/models/irc.dart b/lib/models/irc.dart index 1d9623cc..5d530b23 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -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? 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 = []; - _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, diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index 87eff754..044623d3 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -122,7 +122,9 @@ class _VideoChatState extends State 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 } 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 } 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 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 ), ); - 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( diff --git a/lib/screens/channel/chat/chat.dart b/lib/screens/channel/chat/chat.dart index 0f5f08bb..7c0b63c9 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -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, ), diff --git a/lib/screens/channel/chat/details/chat_modes.dart b/lib/screens/channel/chat/details/chat_modes.dart index b43ec476..403381dc 100644 --- a/lib/screens/channel/chat/details/chat_modes.dart +++ b/lib/screens/channel/chat/details/chat_modes.dart @@ -171,7 +171,7 @@ class ChatModes extends StatelessWidget { style: TextStyle( color: Theme.of( context, - ).colorScheme.onSurfaceVariant, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.7), ), ), ], diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index caf44fb7..9d3c3328 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -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 = []; - /// 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 = []; @@ -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(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 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 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. diff --git a/lib/screens/channel/chat/stores/chat_tabs_store.dart b/lib/screens/channel/chat/stores/chat_tabs_store.dart index 21b9a123..4cf3699d 100644 --- a/lib/screens/channel/chat/stores/chat_tabs_store.dart +++ b/lib/screens/channel/chat/stores/chat_tabs_store.dart @@ -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 = {}; 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 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. diff --git a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart index b947ac7c..e4e30d62 100644 --- a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart +++ b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart @@ -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, ); }, ); diff --git a/lib/screens/channel/chat/widgets/chat_message.dart b/lib/screens/channel/chat/widgets/chat_message.dart index d7645750..87e313d4 100644 --- a/lib/screens/channel/chat/widgets/chat_message.dart +++ b/lib/screens/channel/chat/widgets/chat_message.dart @@ -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; diff --git a/lib/screens/channel/chat/widgets/chat_tabs.dart b/lib/screens/channel/chat/widgets/chat_tabs.dart index cb24cdbf..4abef0ed 100644 --- a/lib/screens/channel/chat/widgets/chat_tabs.dart +++ b/lib/screens/channel/chat/widgets/chat_tabs.dart @@ -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 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(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), + ), + ); + } +} diff --git a/lib/screens/channel/chat/widgets/chat_user_modal.dart b/lib/screens/channel/chat/widgets/chat_user_modal.dart index 2e0700ae..07c975a5 100644 --- a/lib/screens/channel/chat/widgets/chat_user_modal.dart +++ b/lib/screens/channel/chat/widgets/chat_user_modal.dart @@ -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 { IconButton( tooltip: 'Reply', onPressed: () { + widget.onActivateSourceTab?.call(); widget.chatStore.textController.text = '@${widget.username} '; Navigator.pop(context); @@ -115,6 +118,7 @@ class _ChatUserModalState extends State { ircMessage: userMessages[index], chatStore: widget.chatStore, isModal: true, + forceTimestamp: true, ), ), ), diff --git a/lib/screens/channel/chat/widgets/reply_thread.dart b/lib/screens/channel/chat/widgets/reply_thread.dart index 734408b3..7dc83394 100644 --- a/lib/screens/channel/chat/widgets/reply_thread.dart +++ b/lib/screens/channel/chat/widgets/reply_thread.dart @@ -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( diff --git a/lib/screens/channel/video/chat_latency_sync.dart b/lib/screens/channel/video/chat_latency_sync.dart new file mode 100644 index 00000000..0b5ff544 --- /dev/null +++ b/lib/screens/channel/video/chat_latency_sync.dart @@ -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; + } + } +} diff --git a/lib/screens/channel/video/stream_info_poller.dart b/lib/screens/channel/video/stream_info_poller.dart new file mode 100644 index 00000000..5917ffed --- /dev/null +++ b/lib/screens/channel/video/stream_info_poller.dart @@ -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? _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 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 _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.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(); + } + } + } + } +} diff --git a/lib/screens/channel/video/video_overlay.dart b/lib/screens/channel/video/video_overlay.dart index d6a40ea5..a369c9b3 100644 --- a/lib/screens/channel/video/video_overlay.dart +++ b/lib/screens/channel/video/video_overlay.dart @@ -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( - 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( + 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( diff --git a/lib/screens/channel/video/video_store.dart b/lib/screens/channel/video/video_store.dart index a70e7549..c9b2aaae 100644 --- a/lib/screens/channel/video/video_store.dart +++ b/lib/screens/channel/video/video_store.dart @@ -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? _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 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 _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()); } } } diff --git a/lib/screens/channel/video/video_store.g.dart b/lib/screens/channel/video/video_store.g.dart index 5fafe76d..4a329c75 100644 --- a/lib/screens/channel/video/video_store.g.dart +++ b/lib/screens/channel/video/video_store.g.dart @@ -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, diff --git a/lib/screens/channel/video/video_timing_constants.dart b/lib/screens/channel/video/video_timing_constants.dart new file mode 100644 index 00000000..9cd32289 --- /dev/null +++ b/lib/screens/channel/video/video_timing_constants.dart @@ -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); +} diff --git a/lib/screens/home/home.dart b/lib/screens/home/home.dart index 7bc450c7..7e278a81 100644 --- a/lib/screens/home/home.dart +++ b/lib/screens/home/home.dart @@ -166,13 +166,13 @@ class _HomeState extends State { 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 { 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 { 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', diff --git a/lib/screens/settings/chat_settings.dart b/lib/screens/settings/chat_settings.dart index 231ae937..45e10a8d 100644 --- a/lib/screens/settings/chat_settings.dart +++ b/lib/screens/settings/chat_settings.dart @@ -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 createState() => _ChatSettingsState(); -} - -class _ChatSettingsState extends State { - 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 { 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 { ), 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 { 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 { } }, ), - 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 { )], ), 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 { 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, - ), ], ), ); diff --git a/lib/screens/settings/general_settings.dart b/lib/screens/settings/general_settings.dart index e67149f6..6b13f477 100644 --- a/lib/screens/settings/general_settings.dart +++ b/lib/screens/settings/general_settings.dart @@ -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, ), diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index 60c27216..8f430fee 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -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; diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index 4d660dec..46d01e5d 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -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 _$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}, diff --git a/lib/screens/settings/video_settings.dart b/lib/screens/settings/video_settings.dart index 860304a6..80760f06 100644 --- a/lib/screens/settings/video_settings.dart +++ b/lib/screens/settings/video_settings.dart @@ -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, diff --git a/lib/theme.dart b/lib/theme.dart index 588bbbd9..c145a41f 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -37,23 +37,23 @@ class FrostyColors extends ThemeExtension { 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), ), ); diff --git a/lib/utils/context_extensions.dart b/lib/utils/context_extensions.dart index f9cf83bf..faf6a11f 100644 --- a/lib/utils/context_extensions.dart +++ b/lib/utils/context_extensions.dart @@ -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 ===== diff --git a/lib/widgets/chat_input/emote_text_span_builder.dart b/lib/widgets/chat_input/emote_text_span_builder.dart new file mode 100644 index 00000000..785140ff --- /dev/null +++ b/lib/widgets/chat_input/emote_text_span_builder.dart @@ -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 emoteToObject; + + /// Map of user-owned emotes (subscriber, bits, unlocked). + final Map 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 = []; + 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 _splitPreservingSpaces(String text) { + final result = []; + 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; + } +} diff --git a/lib/widgets/expandable_section_header.dart b/lib/widgets/expandable_section_header.dart index 7e3dcb78..6ffe607e 100644 --- a/lib/widgets/expandable_section_header.dart +++ b/lib/widgets/expandable_section_header.dart @@ -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, ), ), diff --git a/lib/widgets/live_indicator.dart b/lib/widgets/live_indicator.dart index cb6ce460..3ef42f18 100644 --- a/lib/widgets/live_indicator.dart +++ b/lib/widgets/live_indicator.dart @@ -138,57 +138,61 @@ class _LiveIndicatorState extends State ); } - 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, + ), + ), + ], + ), ); } } diff --git a/lib/widgets/measure_size.dart b/lib/widgets/measure_size.dart new file mode 100644 index 00000000..b7cba10d --- /dev/null +++ b/lib/widgets/measure_size.dart @@ -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 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 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)); + } +} diff --git a/lib/widgets/section_header.dart b/lib/widgets/section_header.dart index 285244a6..9b5f9e4a 100644 --- a/lib/widgets/section_header.dart +++ b/lib/widgets/section_header.dart @@ -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, + ), ), ), ], diff --git a/lib/widgets/settings_page_layout.dart b/lib/widgets/settings_page_layout.dart index 9b6ebd31..67041760 100644 --- a/lib/widgets/settings_page_layout.dart +++ b/lib/widgets/settings_page_layout.dart @@ -39,7 +39,7 @@ class _SettingsPageLayoutState extends State { // Responsive values based on orientation final listPadding = EdgeInsets.only( - top: isPortrait ? 116 : 92, + top: isPortrait ? 124 : 100, bottom: widget.hasBottomPadding ? context.safePaddingBottom + 8 : 0, ); diff --git a/pubspec.lock b/pubspec.lock index ae8aca1a..fb86872a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml index 7e609323..c0315f24 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/screens/channel/video/chat_latency_sync_test.dart b/test/screens/channel/video/chat_latency_sync_test.dart new file mode 100644 index 00000000..6d9a49fb --- /dev/null +++ b/test/screens/channel/video/chat_latency_sync_test.dart @@ -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); + }); + }); +} diff --git a/test/screens/channel/video/stream_info_poller_test.dart b/test/screens/channel/video/stream_info_poller_test.dart new file mode 100644 index 00000000..935a98ad --- /dev/null +++ b/test/screens/channel/video/stream_info_poller_test.dart @@ -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, + ); + + Channel buildChannel() => Channel.fromJson( + (twitchChannelResponse['data']! as List).first as Map, + ); + + // 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(); + 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); + }, + ); + }); +} diff --git a/test/screens/channel/video/video_timing_constants_test.dart b/test/screens/channel/video/video_timing_constants_test.dart new file mode 100644 index 00000000..dffc210c --- /dev/null +++ b/test/screens/channel/video/video_timing_constants_test.dart @@ -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), + ); + }); + }); +}