chore(sync): merge upstream frosty 5.1.0
This commit is contained in:
commit
218b5408f5
43 changed files with 1909 additions and 632 deletions
2
.github/workflows/gh-release.yml
vendored
2
.github/workflows/gh-release.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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 = "../.."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -176,7 +176,9 @@ class IRCMessage {
|
|||
size: badgeSize,
|
||||
color:
|
||||
Theme.of(context).iconTheme.color?.withValues(alpha: 0.5) ??
|
||||
Colors.grey.withValues(alpha: 0.5),
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -661,6 +663,8 @@ class IRCMessage {
|
|||
bool showMessage = true,
|
||||
Map<String, UserTwitch>? channelIdToUserTwitch,
|
||||
TimestampType timestamp = TimestampType.disabled,
|
||||
bool forceTimestamp = false,
|
||||
bool showHistoricalTimestamps = false,
|
||||
String? currentChannelId,
|
||||
}) {
|
||||
final emoteToObject = assetsStore.emoteToObject;
|
||||
|
|
@ -671,7 +675,19 @@ class IRCMessage {
|
|||
// The span list that will be used to render the chat message
|
||||
final span = <InlineSpan>[];
|
||||
|
||||
_addTimestamp(span, style, timestamp);
|
||||
// When the user has timestamps disabled globally, fall back to the device's
|
||||
// locale preference for historical messages (opt-in via setting) and for
|
||||
// forced contexts like previews.
|
||||
final isHistorical = tags['historical'] == '1';
|
||||
final effectiveTimestamp =
|
||||
(timestamp == TimestampType.disabled &&
|
||||
((isHistorical && showHistoricalTimestamps) || forceTimestamp))
|
||||
? (MediaQuery.alwaysUse24HourFormatOf(context)
|
||||
? TimestampType.twentyFour
|
||||
: TimestampType.twelve)
|
||||
: timestamp;
|
||||
|
||||
_addTimestamp(span, style, effectiveTimestamp);
|
||||
_addHistoricalAndChannelBadges(
|
||||
context,
|
||||
span,
|
||||
|
|
@ -680,7 +696,6 @@ class IRCMessage {
|
|||
currentChannelId,
|
||||
);
|
||||
|
||||
final isHistorical = tags['historical'] == '1';
|
||||
_addUserBadges(
|
||||
context,
|
||||
span,
|
||||
|
|
|
|||
|
|
@ -122,7 +122,9 @@ class _VideoChatState extends State<VideoChat>
|
|||
|
||||
void _handlePipDragStart(DragStartDetails details) {
|
||||
// Disable drag gesture when already in PiP mode or video is not playing
|
||||
if (_videoStore.isInPipMode || _videoStore.paused) return;
|
||||
if (_videoStore.isInPipMode || _videoStore.paused || _videoStore.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
_animationController.stop(); // Stop any ongoing animation
|
||||
setState(() {
|
||||
|
|
@ -133,7 +135,10 @@ class _VideoChatState extends State<VideoChat>
|
|||
}
|
||||
|
||||
void _handlePipDragUpdate(DragUpdateDetails details) {
|
||||
if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) {
|
||||
if (!_isPipDragging ||
|
||||
_videoStore.isInPipMode ||
|
||||
_videoStore.paused ||
|
||||
_videoStore.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -157,7 +162,10 @@ class _VideoChatState extends State<VideoChat>
|
|||
}
|
||||
|
||||
void _handlePipDragEnd(DragEndDetails details) {
|
||||
if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) {
|
||||
if (!_isPipDragging ||
|
||||
_videoStore.isInPipMode ||
|
||||
_videoStore.paused ||
|
||||
_videoStore.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -327,7 +335,9 @@ class _VideoChatState extends State<VideoChat>
|
|||
settingsStore: settingsStore,
|
||||
);
|
||||
|
||||
if (_videoStore.paused || _videoStore.streamInfo == null) {
|
||||
if (_videoStore.paused ||
|
||||
_videoStore.streamInfo == null ||
|
||||
_videoStore.loading) {
|
||||
return videoOverlay;
|
||||
}
|
||||
|
||||
|
|
@ -347,15 +357,44 @@ class _VideoChatState extends State<VideoChat>
|
|||
),
|
||||
);
|
||||
|
||||
final video = Observer(
|
||||
builder: (context) {
|
||||
return Stack(
|
||||
children: [
|
||||
player,
|
||||
if (_videoStore.settingsStore.showOverlay) overlay,
|
||||
],
|
||||
);
|
||||
},
|
||||
final video = Stack(
|
||||
children: [
|
||||
player,
|
||||
Positioned.fill(
|
||||
child: Observer(
|
||||
builder: (_) => AnimatedOpacity(
|
||||
opacity: _videoStore.loading ? 1.0 : 0.0,
|
||||
duration: _videoStore.loading
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
child: IgnorePointer(
|
||||
child: TickerMode(
|
||||
enabled: _videoStore.loading,
|
||||
child: const ColoredBox(
|
||||
color: Colors.black,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white38,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Observer(
|
||||
builder: (_) => _videoStore.settingsStore.showOverlay
|
||||
? overlay
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final chat = Observer(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ class ChatModes extends StatelessWidget {
|
|||
style: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
).colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import 'package:frosty/screens/settings/stores/auth_store.dart';
|
|||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
import 'package:frosty/utils.dart';
|
||||
import 'package:mobx/mobx.dart';
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
part 'chat_store.g.dart';
|
||||
|
|
@ -65,32 +66,41 @@ abstract class ChatStoreBase with Store {
|
|||
'msg_bad_characters',
|
||||
};
|
||||
|
||||
/// Base height of the bottom bar (input field area).
|
||||
/// Fallback height used to pad the message list for one frame, until the
|
||||
/// real bottom bar reports its measured height via [setBottomBarHeight].
|
||||
static const _baseBottomBarHeight = 68.0;
|
||||
|
||||
/// Height of the autocomplete bar (SizedBox + Divider).
|
||||
static const _autocompleteHeight = 51.0;
|
||||
/// Server-initiated graceful reconnect notice. Twitch sends this untagged
|
||||
/// before maintenance so clients can re-establish ahead of the close.
|
||||
static const _serverReconnectNotice = ':tmi.twitch.tv RECONNECT';
|
||||
|
||||
/// Height of the reply bar (Container + Divider).
|
||||
static const _replyBarHeight = 41.0;
|
||||
/// Muted words pre-normalized (trimmed, lowercased, empties dropped) by a
|
||||
/// reaction on the setting — avoids re-normalizing on every message.
|
||||
var _normalizedMutedWords = <String>[];
|
||||
|
||||
/// Checks if IRC data contains a command that should bypass the chat delay.
|
||||
/// Returns true if any message in the data contains a bypass command.
|
||||
bool _shouldBypassDelay(String data) {
|
||||
for (final message in data.trimRight().split('\r\n')) {
|
||||
if (message.startsWith('@')) {
|
||||
final parts = message.split(' ');
|
||||
if (parts.length >= 3 && _delayBypassCommands.contains(parts[2])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
/// Whether a single IRC [line] should bypass the chat delay and be handled
|
||||
/// immediately. Displayable chat content (PRIVMSG/USERNOTICE and the
|
||||
/// moderation CLEARCHAT/CLEARMSG) is delayed, so it stays in sync with the
|
||||
/// delayed video. Everything else is handled at once: control/state messages
|
||||
/// (USERSTATE/ROOMSTATE/GLOBALUSERSTATE), NOTICE (so a send rejection surfaces
|
||||
/// immediately instead of one chat-delay later), the reconnect notice, and
|
||||
/// any untagged server line (PING keepalives, CAP, JOIN, the welcome banner)
|
||||
/// — delaying those would stall the send-ack, drop the PONG, or never start
|
||||
/// the buffer drain.
|
||||
bool _lineShouldBypassDelay(String line) {
|
||||
if (line.isEmpty || line == _serverReconnectNotice) return true;
|
||||
// Displayable chat content is always IRCv3-tagged; an untagged line is
|
||||
// never delayed.
|
||||
if (!line.startsWith('@')) return true;
|
||||
final parts = line.split(' ');
|
||||
return parts.length >= 3 && _delayBypassCommands.contains(parts[2]);
|
||||
}
|
||||
|
||||
int get _chatDelaySeconds => settings.effectiveChatDelay.toInt();
|
||||
Duration get _chatDelay =>
|
||||
Duration(milliseconds: (settings.effectiveChatDelay * 1000).round());
|
||||
|
||||
bool get _hasActiveChatDelay => settings.showVideo && _chatDelaySeconds > 0;
|
||||
bool get _hasActiveChatDelay =>
|
||||
settings.showVideo && _chatDelay > Duration.zero;
|
||||
|
||||
final TwitchApi twitchApi;
|
||||
|
||||
|
|
@ -130,6 +140,12 @@ abstract class ChatStoreBase with Store {
|
|||
|
||||
static const _maxRetries = 5;
|
||||
|
||||
/// Ping interval for both the IRC and 7TV WebSocket sockets. Catches
|
||||
/// zombie connections (silent network changes, OS suspends) without
|
||||
/// waking the radio every 15s. Twitch's own recommended keepalive tops
|
||||
/// out at 600s; community libs (e.g. dank-twitch-irc) sit around 30s.
|
||||
static const _pingInterval = Duration(seconds: 60);
|
||||
|
||||
// The retry counter for exponential backoff.
|
||||
var _retries = 0;
|
||||
|
||||
|
|
@ -157,6 +173,12 @@ abstract class ChatStoreBase with Store {
|
|||
/// Requested message to be sent by the user. Will only be sent on receipt of a USERNOTICE command.
|
||||
IRCMessage? toSend;
|
||||
|
||||
/// Raw text of the most recent outgoing message, retained until the send is
|
||||
/// confirmed. Lets the USERSTATE handler build the local echo even when the
|
||||
/// message was sent before this session's first USERSTATE arrived (so
|
||||
/// [toSend] couldn't be built up front).
|
||||
String? _pendingSentMessageText;
|
||||
|
||||
/// The list of reaction disposer functions that will be used later when disposing.
|
||||
final reactions = <ReactionDisposer>[];
|
||||
|
||||
|
|
@ -299,25 +321,26 @@ abstract class ChatStoreBase with Store {
|
|||
.toList();
|
||||
}
|
||||
|
||||
/// Current bottom bar height based on visible overlays.
|
||||
/// Used to dynamically adjust chat message list padding.
|
||||
/// Measured height of the rendered bottom bar (input plus any reply or
|
||||
/// autocomplete overlays). The bar reports this via [setBottomBarHeight] so
|
||||
/// the message list's bottom padding tracks the bar's *true* height —
|
||||
/// including multi-line input growth and OS text scaling, which fixed
|
||||
/// per-overlay constants silently undercount.
|
||||
final _measuredBottomBarHeight = Observable<double?>(null);
|
||||
|
||||
/// Current bottom bar height, used to dynamically pad the message list so the
|
||||
/// bar never overlaps messages. Falls back to the single-line base height
|
||||
/// until the real bar reports its first measurement.
|
||||
@computed
|
||||
double get bottomBarHeight {
|
||||
var height = _baseBottomBarHeight;
|
||||
double get bottomBarHeight =>
|
||||
_measuredBottomBarHeight.value ?? _baseBottomBarHeight;
|
||||
|
||||
// Add reply bar height when replying to a message.
|
||||
if (replyingToMessage != null) {
|
||||
height += _replyBarHeight;
|
||||
}
|
||||
|
||||
// Add autocomplete bar height when showing matches.
|
||||
if (settings.autocomplete &&
|
||||
((_showEmoteAutocomplete && matchingEmotes.isNotEmpty) ||
|
||||
(_showMentionAutocomplete && matchingChatters.isNotEmpty))) {
|
||||
height += _autocompleteHeight;
|
||||
}
|
||||
|
||||
return height;
|
||||
/// Reports the bar's real rendered height. Sub-pixel changes are ignored to
|
||||
/// avoid needless message-list relayouts.
|
||||
void setBottomBarHeight(double height) {
|
||||
final current = _measuredBottomBarHeight.value;
|
||||
if (current != null && (current - height).abs() < 0.5) return;
|
||||
runInAction(() => _measuredBottomBarHeight.value = height);
|
||||
}
|
||||
|
||||
ChatStoreBase({
|
||||
|
|
@ -336,6 +359,17 @@ abstract class ChatStoreBase with Store {
|
|||
reaction((_) => auth.isLoggedIn, (_) => _channel?.sink.close(1000)),
|
||||
);
|
||||
|
||||
// Normalize muted words once per settings change instead of per message
|
||||
// — the filter runs in the IRC hot path (50+ msgs/s in busy channels).
|
||||
reactions.add(
|
||||
reaction((_) => settings.mutedWords.toList(), (List<String> words) {
|
||||
_normalizedMutedWords = words
|
||||
.map((word) => word.trim().toLowerCase())
|
||||
.where((word) => word.isNotEmpty)
|
||||
.toList();
|
||||
}, fireImmediately: true),
|
||||
);
|
||||
|
||||
reactions.add(
|
||||
reaction(
|
||||
(_) => [
|
||||
|
|
@ -448,7 +482,7 @@ abstract class ChatStoreBase with Store {
|
|||
) {
|
||||
queue.add(
|
||||
_DelayedCallback(
|
||||
releaseAt: DateTime.now().add(Duration(seconds: _chatDelaySeconds)),
|
||||
releaseAt: DateTime.now().add(_chatDelay),
|
||||
callback: callback,
|
||||
),
|
||||
);
|
||||
|
|
@ -517,6 +551,15 @@ abstract class ChatStoreBase with Store {
|
|||
// To account for this, split by CRLF, then loop and process each message.
|
||||
for (final message in data.trimRight().split('\r\n')) {
|
||||
// debugPrint('$message\n');
|
||||
if (message == _serverReconnectNotice) {
|
||||
// Twitch is about to close the socket for maintenance — close it
|
||||
// ourselves so the existing onDone path reconnects with no backoff
|
||||
// (first retry is immediate) instead of waiting for the server to
|
||||
// drop us mid-message.
|
||||
_channel?.sink.close(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.startsWith('@')) {
|
||||
// Parse defensively: a single malformed message must never abort the
|
||||
// loop, or one bad frame would drop every other message batched with
|
||||
|
|
@ -548,19 +591,17 @@ abstract class ChatStoreBase with Store {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Filter messages containing any muted words.
|
||||
if (parsedIRCMessage.message != null) {
|
||||
final List<String> mutedWords = settings.mutedWords;
|
||||
|
||||
// check if the message contains any of the muted words
|
||||
for (final word in mutedWords) {
|
||||
if (parsedIRCMessage.message!
|
||||
.toLowerCase()
|
||||
.split(settings.matchWholeWord ? ' ' : '')
|
||||
.contains(word.toLowerCase())) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Filter messages containing any muted words. Must use `continue`
|
||||
// (not `return`) — a single IRC frame can batch many messages, and
|
||||
// returning would drop the rest of the frame with the matched one.
|
||||
if (parsedIRCMessage.message != null &&
|
||||
_normalizedMutedWords.isNotEmpty) {
|
||||
final msg = parsedIRCMessage.message!.toLowerCase();
|
||||
final words = settings.matchWholeWord ? msg.split(' ') : null;
|
||||
final muted = _normalizedMutedWords.any(
|
||||
(word) => words != null ? words.contains(word) : msg.contains(word),
|
||||
);
|
||||
if (muted) continue;
|
||||
}
|
||||
|
||||
switch (parsedIRCMessage.command) {
|
||||
|
|
@ -576,6 +617,9 @@ abstract class ChatStoreBase with Store {
|
|||
_sendingTimeoutTimer?.cancel();
|
||||
_isWaitingForAck = false;
|
||||
toSend = null;
|
||||
// Drop the retained text so a later USERSTATE can't render the
|
||||
// rejected message as a deferred echo.
|
||||
_pendingSentMessageText = null;
|
||||
|
||||
// Show notification with Twitch's rejection message
|
||||
if (parsedIRCMessage.message != null) {
|
||||
|
|
@ -614,7 +658,7 @@ abstract class ChatStoreBase with Store {
|
|||
!_isInSharedChatMode &&
|
||||
assetsStore.hasLoadedSharedChatAssets) {
|
||||
// On leaving shared chat, drop the participants' merged emotes and
|
||||
// badges and restore just this channel's assets; otherwise the
|
||||
// badges and restore just this channel's assets — otherwise the
|
||||
// other streamer's emote set and badges linger (#522). Guarded on
|
||||
// having actually loaded shared assets so a flapping tag can't
|
||||
// trigger redundant refetches.
|
||||
|
|
@ -644,23 +688,13 @@ abstract class ChatStoreBase with Store {
|
|||
case Command.userState:
|
||||
_userState = _userState.fromIRCMessage(parsedIRCMessage);
|
||||
|
||||
// USERSTATE arrival confirms message was sent, even without id tag
|
||||
if (toSend != null) {
|
||||
final messageId = parsedIRCMessage.tags['id'];
|
||||
if (messageId != null) {
|
||||
toSend!.tags['id'] = messageId;
|
||||
} else {
|
||||
// Fallback ID for edge cases where Twitch doesn't provide one
|
||||
toSend!.tags['id'] =
|
||||
'local-${DateTime.now().millisecondsSinceEpoch}';
|
||||
}
|
||||
messageBuffer.add(toSend!);
|
||||
toSend = null;
|
||||
// Reset sending state and clear text/reply after successful confirmation
|
||||
_sendingTimeoutTimer?.cancel();
|
||||
_isWaitingForAck = false;
|
||||
textController.clear();
|
||||
replyingToMessage = null;
|
||||
// A USERSTATE following our PRIVMSG confirms the send. Finalize
|
||||
// regardless of whether a local echo was pre-built — gating this on
|
||||
// `toSend` left the player stuck "sending" (and firing a false
|
||||
// "may not have been sent") whenever the message went out before
|
||||
// this session's first USERSTATE populated `_userState`.
|
||||
if (_isWaitingForAck) {
|
||||
_finalizeSend(parsedIRCMessage.tags['id']);
|
||||
}
|
||||
break;
|
||||
case Command.globalUserState:
|
||||
|
|
@ -824,8 +858,9 @@ abstract class ChatStoreBase with Store {
|
|||
|
||||
_sevenTVChannel?.sink.close(1000);
|
||||
_clearPendingDelayedCallbacks(clearChat: false);
|
||||
_sevenTVChannel = WebSocketChannel.connect(
|
||||
_sevenTVChannel = IOWebSocketChannel.connect(
|
||||
Uri.parse('wss://events.7tv.io/v3'),
|
||||
pingInterval: _pingInterval,
|
||||
);
|
||||
|
||||
void listener(dynamic data) {
|
||||
|
|
@ -905,8 +940,9 @@ abstract class ChatStoreBase with Store {
|
|||
_clearPendingDelayedCallbacks(clearSevenTV: false);
|
||||
|
||||
_channel?.sink.close(1000);
|
||||
_channel = WebSocketChannel.connect(
|
||||
_channel = IOWebSocketChannel.connect(
|
||||
Uri.parse('wss://irc-ws.chat.twitch.tv:443'),
|
||||
pingInterval: _pingInterval,
|
||||
);
|
||||
|
||||
// Only show chat delay countdown on initial connection or video toggle, not on reconnects
|
||||
|
|
@ -919,13 +955,27 @@ abstract class ChatStoreBase with Store {
|
|||
(data) {
|
||||
final dataStr = data.toString();
|
||||
|
||||
// Process immediately if delay is disabled or command should bypass delay
|
||||
if (!_hasActiveChatDelay || _shouldBypassDelay(dataStr)) {
|
||||
// No active delay: process the whole frame immediately.
|
||||
if (!_hasActiveChatDelay) {
|
||||
_handleIRCData(dataStr);
|
||||
} else {
|
||||
_enqueueDelayedCallback(_pendingChatCallbacks, () {
|
||||
_handleIRCData(dataStr);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Dispatch each line individually so a bypass-eligible control message
|
||||
// isn't dragged behind the delay by chat lines batched in the same
|
||||
// frame — and, conversely, so a delayed chat line can't hold up a
|
||||
// PING/USERSTATE that shares the frame.
|
||||
for (final line in dataStr.split('\r\n')) {
|
||||
final trimmed = line.trimRight();
|
||||
if (trimmed.isEmpty) continue;
|
||||
if (_lineShouldBypassDelay(trimmed)) {
|
||||
_handleIRCData(trimmed);
|
||||
} else {
|
||||
_enqueueDelayedCallback(
|
||||
_pendingChatCallbacks,
|
||||
() => _handleIRCData(trimmed),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
|
|
@ -941,6 +991,14 @@ abstract class ChatStoreBase with Store {
|
|||
_cancelChatDelayCountdown();
|
||||
_chatDelaySyncCompleted = false;
|
||||
|
||||
// Reset send state — any in-flight message is unrecoverable across
|
||||
// a disconnect, so don't leave the user blocked waiting for an ack
|
||||
// that will never arrive.
|
||||
_sendingTimeoutTimer?.cancel();
|
||||
_isWaitingForAck = false;
|
||||
toSend = null;
|
||||
_pendingSentMessageText = null;
|
||||
|
||||
if (_shouldDisconnect) {
|
||||
_sevenTVChannel?.sink.close(1000);
|
||||
return;
|
||||
|
|
@ -1111,7 +1169,7 @@ abstract class ChatStoreBase with Store {
|
|||
messageBuffer.clear();
|
||||
}
|
||||
|
||||
var remainingSeconds = _chatDelaySeconds;
|
||||
var remainingSeconds = _chatDelay.inSeconds;
|
||||
|
||||
// Create and store reference to the countdown message
|
||||
_countdownMessage = IRCMessage.createNotice(
|
||||
|
|
@ -1196,53 +1254,93 @@ abstract class ChatStoreBase with Store {
|
|||
// Text field and reply state cleared on USERSTATE confirmation (not optimistically)
|
||||
// to preserve message text if Twitch rejects it (e.g., slow mode)
|
||||
|
||||
// Start timeout timer in case server doesn't respond (network failure)
|
||||
// Must be outside userStateString conditional to always have a safety net
|
||||
// Fallback in case the confirming USERSTATE never arrives. Twitch can omit
|
||||
// or drop the USERSTATE that acknowledges a send — intermittently, and more
|
||||
// often under a long chat delay — even though the message was delivered.
|
||||
// A missing ack is therefore NOT evidence of failure, so rather than cry
|
||||
// wolf with a "may not have been sent" error (which pushes the user to
|
||||
// resend and double-post), optimistically finalize the send: render the
|
||||
// local echo and clear the pending state. Genuine rejections almost always
|
||||
// arrive first — the rejection NOTICE bypasses the chat delay, so it lands
|
||||
// well within this window and clears the pending send before the timeout. A
|
||||
// rejection arriving after the timeout has finalized is treated as sent (the
|
||||
// echo stays), an accepted edge given how early NOTICEs arrive. Scaled by
|
||||
// the chat delay so a legitimately delayed USERSTATE is given time to land.
|
||||
_sendingTimeoutTimer?.cancel();
|
||||
_sendingTimeoutTimer = Timer(const Duration(seconds: 10), () {
|
||||
// Guard against execution after disposal
|
||||
if (_shouldDisconnect) return;
|
||||
|
||||
if (_isWaitingForAck) {
|
||||
_isWaitingForAck = false;
|
||||
toSend = null;
|
||||
HapticFeedback.heavyImpact();
|
||||
updateNotification('Message may not have been sent. Please try again.');
|
||||
}
|
||||
_sendingTimeoutTimer = Timer(const Duration(seconds: 10) + _chatDelay, () {
|
||||
if (_shouldDisconnect || !_isWaitingForAck) return;
|
||||
runInAction(() => _finalizeSend(null));
|
||||
});
|
||||
|
||||
// Obtain the logged-in user's appearance in chat with USERSTATE and create the full message to render.
|
||||
var userStateString = _userState.raw;
|
||||
if (userStateString != null) {
|
||||
if (message.length > 3 && message.substring(0, 3) == '/me') {
|
||||
userStateString +=
|
||||
' :\x01ACTION ${message.replaceRange(0, 3, '').trim()}\x01';
|
||||
} else {
|
||||
userStateString +=
|
||||
' :${replyingToMessage?.tags['display-name'] != null ? '@${replyingToMessage!.tags['display-name']} ' : ''}${message.trim()}';
|
||||
}
|
||||
// Build the optimistic local echo from the current USERSTATE. If the
|
||||
// message is sent before this session's first USERSTATE arrives,
|
||||
// _buildLocalEcho returns null and the echo is built later, when the
|
||||
// confirming USERSTATE lands (see the Command.userState handler). The raw
|
||||
// text is retained so that deferred build is possible.
|
||||
_pendingSentMessageText = message;
|
||||
toSend = _buildLocalEcho(message);
|
||||
}
|
||||
|
||||
final userChatMessage = IRCMessage.fromString(userStateString);
|
||||
userChatMessage.localEmotes?.addAll(assetsStore.userEmoteToObject);
|
||||
if (auth.isLoggedIn && auth.user.details != null) {
|
||||
userChatMessage.tags['user-id'] = auth.user.details!.id;
|
||||
}
|
||||
|
||||
if (replyingToMessage != null && replyingToMessage!.tags['id'] != null) {
|
||||
userChatMessage.tags['reply-parent-msg-id'] =
|
||||
replyingToMessage!.tags['id']!;
|
||||
userChatMessage.tags['reply-parent-display-name'] =
|
||||
replyingToMessage!.tags['display-name'] ?? '';
|
||||
userChatMessage.tags['reply-parent-msg-body'] =
|
||||
replyingToMessage!.message ?? '';
|
||||
}
|
||||
|
||||
// Ensure a timestamp so merged-mode sorting works correctly.
|
||||
userChatMessage.tags['tmi-sent-ts'] ??=
|
||||
'${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
toSend = userChatMessage;
|
||||
/// Renders the pending outgoing message's local echo (building it lazily if
|
||||
/// it couldn't be built at send time) and clears all send-pending state.
|
||||
/// Shared by the USERSTATE confirmation and the optimistic timeout fallback.
|
||||
/// [serverMessageId] is the `id` tag from the confirming USERSTATE, or null
|
||||
/// when finalizing optimistically without one.
|
||||
void _finalizeSend(String? serverMessageId) {
|
||||
toSend ??= _pendingSentMessageText != null
|
||||
? _buildLocalEcho(_pendingSentMessageText!)
|
||||
: null;
|
||||
if (toSend != null) {
|
||||
// Prefer the server id; fall back to any id already set, then a local one.
|
||||
toSend!.tags['id'] =
|
||||
serverMessageId ??
|
||||
toSend!.tags['id'] ??
|
||||
'local-${DateTime.now().millisecondsSinceEpoch}';
|
||||
messageBuffer.add(toSend!);
|
||||
toSend = null;
|
||||
}
|
||||
_pendingSentMessageText = null;
|
||||
_sendingTimeoutTimer?.cancel();
|
||||
_isWaitingForAck = false;
|
||||
textController.clear();
|
||||
replyingToMessage = null;
|
||||
}
|
||||
|
||||
/// Builds the optimistic local echo of an outgoing [message] from the current
|
||||
/// USERSTATE, or null if no USERSTATE has been received yet this session
|
||||
/// (the echo can't be rendered without the user's badges/color/display-name).
|
||||
IRCMessage? _buildLocalEcho(String message) {
|
||||
var userStateString = _userState.raw;
|
||||
if (userStateString == null) return null;
|
||||
|
||||
if (message == '/me' || message.startsWith('/me ')) {
|
||||
userStateString +=
|
||||
' :\x01ACTION ${message.replaceRange(0, 3, '').trim()}\x01';
|
||||
} else {
|
||||
userStateString +=
|
||||
' :${replyingToMessage?.tags['display-name'] != null ? '@${replyingToMessage!.tags['display-name']} ' : ''}${message.trim()}';
|
||||
}
|
||||
|
||||
final userChatMessage = IRCMessage.fromString(userStateString);
|
||||
userChatMessage.localEmotes?.addAll(assetsStore.userEmoteToObject);
|
||||
if (auth.isLoggedIn && auth.user.details != null) {
|
||||
userChatMessage.tags['user-id'] = auth.user.details!.id;
|
||||
}
|
||||
|
||||
if (replyingToMessage != null && replyingToMessage!.tags['id'] != null) {
|
||||
userChatMessage.tags['reply-parent-msg-id'] =
|
||||
replyingToMessage!.tags['id']!;
|
||||
userChatMessage.tags['reply-parent-display-name'] =
|
||||
replyingToMessage!.tags['display-name'] ?? '';
|
||||
userChatMessage.tags['reply-parent-msg-body'] =
|
||||
replyingToMessage!.message ?? '';
|
||||
}
|
||||
|
||||
// Ensure a timestamp so merged-mode sorting works correctly.
|
||||
userChatMessage.tags['tmi-sent-ts'] ??=
|
||||
'${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
return userChatMessage;
|
||||
}
|
||||
|
||||
/// Adds the given [emote] to the chat textfield.
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ class ChatTabInfo {
|
|||
/// The ChatStore for this tab. Null until the tab is activated (lazy loading).
|
||||
ChatStore? chatStore;
|
||||
|
||||
/// The id of the most recent message visible the last time the user viewed
|
||||
/// this tab. Used to compute unread state — null means "no baseline yet".
|
||||
String? lastSeenMessageId;
|
||||
|
||||
/// Whether this is the primary tab (first tab, cannot be removed).
|
||||
final bool isPrimary;
|
||||
|
||||
|
|
@ -184,8 +188,9 @@ abstract class ChatTabsStoreBase with Store {
|
|||
int? tailCount,
|
||||
}) {
|
||||
final msgs = store.messages;
|
||||
final start =
|
||||
tailCount != null && msgs.length > tailCount ? msgs.length - tailCount : 0;
|
||||
final start = tailCount != null && msgs.length > tailCount
|
||||
? msgs.length - tailCount
|
||||
: 0;
|
||||
for (var i = start; i < msgs.length; i++) {
|
||||
final id = msgs[i].tags['id'];
|
||||
if (id != null && !seenIds.add(id)) continue;
|
||||
|
|
@ -221,7 +226,12 @@ abstract class ChatTabsStoreBase with Store {
|
|||
final seenIds = <String>{};
|
||||
for (final tab in _tabs) {
|
||||
if (tab.chatStore == null) continue;
|
||||
_collectMessages(tab.chatStore!, recent, seenIds, tailCount: _mergedRenderLimit);
|
||||
_collectMessages(
|
||||
tab.chatStore!,
|
||||
recent,
|
||||
seenIds,
|
||||
tailCount: _mergedRenderLimit,
|
||||
);
|
||||
}
|
||||
recent.sort(_compareByTimestamp);
|
||||
if (recent.length > _mergedRenderLimit) {
|
||||
|
|
@ -256,7 +266,8 @@ abstract class ChatTabsStoreBase with Store {
|
|||
for (final tab in _tabs) {
|
||||
if (tab.chatStore != null) {
|
||||
total +=
|
||||
tab.chatStore!.messages.length + tab.chatStore!.messageBuffer.length;
|
||||
tab.chatStore!.messages.length +
|
||||
tab.chatStore!.messageBuffer.length;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
|
|
@ -515,10 +526,9 @@ abstract class ChatTabsStoreBase with Store {
|
|||
),
|
||||
);
|
||||
|
||||
// Switch to the new tab (this will activate it)
|
||||
// Switch to the new tab, snapshotting the outgoing tab's lastSeenMessageId.
|
||||
final newIndex = _tabs.length - 1;
|
||||
activateTab(newIndex);
|
||||
activeTabIndex = newIndex;
|
||||
setActiveTab(newIndex);
|
||||
|
||||
// If in merged mode, fetch the new tab's channel profile
|
||||
if (mergedMode) {
|
||||
|
|
@ -615,8 +625,6 @@ abstract class ChatTabsStoreBase with Store {
|
|||
|
||||
/// Reorders a tab from oldIndex to newIndex.
|
||||
/// The primary tab (index 0) cannot be moved, and no tab can be placed before it.
|
||||
/// The caller must pass an insertion index already adjusted for Flutter's
|
||||
/// ReorderableListView remove-then-insert behavior.
|
||||
@action
|
||||
void reorderTab(int oldIndex, int newIndex) {
|
||||
// Primary tab (index 0) cannot be moved, and nothing can move before it
|
||||
|
|
@ -641,6 +649,30 @@ abstract class ChatTabsStoreBase with Store {
|
|||
_syncSecondaryTabsToSettings();
|
||||
}
|
||||
|
||||
/// Returns the last message id in [messages] that has a non-null tag id,
|
||||
/// or null if no such message exists. Skips system messages without ids.
|
||||
String? _latestMessageId(List<IRCMessage> messages) {
|
||||
for (var i = messages.length - 1; i >= 0; i--) {
|
||||
final id = messages[i].tags['id'];
|
||||
if (id != null) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// True if [index] has unread messages since the user last viewed it.
|
||||
/// Always false for the active tab and in merged mode.
|
||||
bool hasUnreadMessages(int index) {
|
||||
if (mergedMode) return false;
|
||||
if (index == activeTabIndex) return false;
|
||||
if (index < 0 || index >= _tabs.length) return false;
|
||||
final tab = _tabs[index];
|
||||
final store = tab.chatStore;
|
||||
if (store == null) return false;
|
||||
final latest = _latestMessageId(store.messages);
|
||||
if (latest == null) return false;
|
||||
return latest != tab.lastSeenMessageId;
|
||||
}
|
||||
|
||||
/// Sets the active tab to the given index.
|
||||
///
|
||||
/// When [silent] is true, the current tab's draft text and reply state are
|
||||
|
|
@ -649,6 +681,18 @@ abstract class ChatTabsStoreBase with Store {
|
|||
@action
|
||||
void setActiveTab(int index, {bool silent = false}) {
|
||||
if (index >= 0 && index < _tabs.length) {
|
||||
// Snapshot the outgoing tab's most recent message id so the unread
|
||||
// dot for that tab clears, and only future messages count as unread.
|
||||
if (index != activeTabIndex &&
|
||||
activeTabIndex >= 0 &&
|
||||
activeTabIndex < _tabs.length) {
|
||||
final outgoing = _tabs[activeTabIndex];
|
||||
final outStore = outgoing.chatStore;
|
||||
if (outStore != null) {
|
||||
outgoing.lastSeenMessageId = _latestMessageId(outStore.messages);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear text input and emote menu when switching tabs.
|
||||
// Skip in merged mode — tabs act as send-target selectors, not
|
||||
// view switches, so draft/reply state should be preserved.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ class ChatTabs extends StatelessWidget {
|
|||
);
|
||||
|
||||
if (!added && context.mounted) {
|
||||
// If not added, it means the channel already exists (switched to it)
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Channel already open, switched to it'),
|
||||
|
|
@ -56,13 +55,8 @@ class ChatTabs extends StatelessWidget {
|
|||
final showTabBar = chatTabsStore.showTabBar;
|
||||
final showMerge = tabs.where((t) => t.isActivated).length >= 2;
|
||||
|
||||
// Calculate extra top padding for tab bar when visible
|
||||
final tabBarHeight = showTabBar ? 48.0 : 0.0;
|
||||
|
||||
// Get the top inset from listPadding (e.g., for AppBar in chat-only mode)
|
||||
final topInset = listPadding?.resolve(TextDirection.ltr).top ?? 0;
|
||||
|
||||
// Adjust list padding to account for tab bar
|
||||
final adjustedPadding = listPadding != null
|
||||
? listPadding!.add(EdgeInsets.only(top: tabBarHeight))
|
||||
: EdgeInsets.only(top: tabBarHeight);
|
||||
|
|
@ -72,27 +66,20 @@ class ChatTabs extends StatelessWidget {
|
|||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) return;
|
||||
|
||||
// On Android back gesture, close overlays first before navigating back
|
||||
// Android back gesture: dismiss overlays before navigating away.
|
||||
final activeStore = chatTabsStore.activeChatStore;
|
||||
|
||||
// Priority 1: Close emote menu
|
||||
if (activeStore.assetsStore.showEmoteMenu) {
|
||||
activeStore.assetsStore.showEmoteMenu = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Priority 2: Unfocus keyboard
|
||||
if (activeStore.textFieldFocusNode.hasFocus) {
|
||||
activeStore.unfocusInput();
|
||||
return;
|
||||
}
|
||||
|
||||
// Priority 3: Navigate back
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
// Chat content: merged view or IndexedStack
|
||||
Positioned.fill(
|
||||
child: chatTabsStore.mergedMode
|
||||
? Chat(
|
||||
|
|
@ -104,7 +91,6 @@ class ChatTabs extends StatelessWidget {
|
|||
: IndexedStack(
|
||||
index: activeIndex,
|
||||
children: tabs.map((tabInfo) {
|
||||
// Show placeholder for non-activated tabs
|
||||
if (tabInfo.chatStore == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
|
@ -117,7 +103,6 @@ class ChatTabs extends StatelessWidget {
|
|||
}).toList(),
|
||||
),
|
||||
),
|
||||
// Tab bar (only visible when more than 1 tab)
|
||||
if (showTabBar)
|
||||
Positioned(
|
||||
top: topInset,
|
||||
|
|
@ -127,8 +112,8 @@ class ChatTabs extends StatelessWidget {
|
|||
height: 48,
|
||||
child: Stack(
|
||||
children: [
|
||||
// When merge button is visible, clips at its center
|
||||
// so chips slide under its left half then disappear.
|
||||
// Right-clip past the merge button so chips slide
|
||||
// under its left half then disappear.
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
|
|
@ -158,7 +143,7 @@ class ChatTabs extends StatelessWidget {
|
|||
padding: EdgeInsets.only(
|
||||
right: index < tabs.length - 1 ? 4 : 0,
|
||||
),
|
||||
child: _buildTab(context, index),
|
||||
child: Center(child: _buildTab(context, index)),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -168,7 +153,9 @@ class ChatTabs extends StatelessWidget {
|
|||
right: 12,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Center(child: _buildMergeToggle(context)),
|
||||
child: Center(
|
||||
child: _buildMoreActionsMenu(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -181,22 +168,48 @@ class ChatTabs extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildMergeToggle(BuildContext context) {
|
||||
final isMerged = chatTabsStore.mergedMode;
|
||||
return IconButton.filledTonal(
|
||||
icon: const Icon(Icons.call_merge, size: 18),
|
||||
tooltip: isMerged ? 'Split chats' : 'Merge loaded chats',
|
||||
visualDensity: VisualDensity.compact,
|
||||
isSelected: isMerged,
|
||||
style: IconButton.styleFrom(
|
||||
minimumSize: const Size(42, 42),
|
||||
backgroundColor: isMerged
|
||||
? null
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
onPressed: () {
|
||||
HapticFeedback.selectionClick();
|
||||
chatTabsStore.toggleMergedMode();
|
||||
Widget _buildMoreActionsMenu(BuildContext context) {
|
||||
final settings = chatTabsStore.activeChatStore.settings;
|
||||
|
||||
return _AnchoredPopupMenu(
|
||||
itemsBuilder: (close) => [
|
||||
Observer(
|
||||
builder: (_) => CheckboxMenuButton(
|
||||
value: chatTabsStore.mergedMode,
|
||||
onChanged: (_) {
|
||||
HapticFeedback.selectionClick();
|
||||
chatTabsStore.toggleMergedMode();
|
||||
},
|
||||
child: const Text('Merge chats'),
|
||||
),
|
||||
),
|
||||
Observer(
|
||||
builder: (_) => CheckboxMenuButton(
|
||||
value: settings.focusCurrentChannel,
|
||||
onChanged: (newValue) {
|
||||
HapticFeedback.selectionClick();
|
||||
settings.focusCurrentChannel = newValue ?? false;
|
||||
},
|
||||
child: const Text('Focus current channel'),
|
||||
),
|
||||
),
|
||||
],
|
||||
anchorBuilder: (context, toggle) {
|
||||
return IconButton.filledTonal(
|
||||
icon: const Icon(Icons.more_vert, size: 18),
|
||||
tooltip: 'Chat options',
|
||||
visualDensity: VisualDensity.compact,
|
||||
style: IconButton.styleFrom(
|
||||
minimumSize: const Size(42, 42),
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
onPressed: () {
|
||||
HapticFeedback.selectionClick();
|
||||
toggle();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -204,85 +217,209 @@ class ChatTabs extends StatelessWidget {
|
|||
Widget _buildTab(BuildContext context, int index) {
|
||||
final tabInfo = chatTabsStore.tabs[index];
|
||||
final isActive = index == chatTabsStore.activeTabIndex;
|
||||
final isActivated = tabInfo.isActivated;
|
||||
final isSecondary = !tabInfo.isPrimary;
|
||||
final displayName = getReadableName(
|
||||
tabInfo.displayName,
|
||||
tabInfo.channelLogin,
|
||||
);
|
||||
|
||||
final avatar = ProfilePicture(userLogin: tabInfo.channelLogin, radius: 12);
|
||||
return Observer(
|
||||
builder: (context) {
|
||||
final theme = Theme.of(context);
|
||||
final isActivated = tabInfo.isActivated;
|
||||
final hasUnread = chatTabsStore.hasUnreadMessages(index);
|
||||
final dimmed =
|
||||
!isActivated || !(tabInfo.chatStore?.isConnected ?? false);
|
||||
|
||||
if (tabInfo.isPrimary) {
|
||||
return InputChip(
|
||||
avatar: avatar,
|
||||
label: Text(displayName),
|
||||
selected: isActive,
|
||||
showCheckmark: false,
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
onPressed: () {
|
||||
if (!isActive) {
|
||||
HapticFeedback.selectionClick();
|
||||
chatTabsStore.setActiveTab(index);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return MenuAnchor(
|
||||
menuChildren: [
|
||||
if (isActivated)
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(Icons.power_off_rounded, size: 18),
|
||||
child: const Text('Disconnect'),
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
chatTabsStore.deactivateTab(index);
|
||||
},
|
||||
InputChip buildChip({VoidCallback? onDeleted}) => InputChip(
|
||||
avatar: Badge(
|
||||
smallSize: 8,
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
isLabelVisible: hasUnread,
|
||||
child: Opacity(
|
||||
opacity: dimmed ? 0.5 : 1.0,
|
||||
child: ProfilePicture(
|
||||
userLogin: tabInfo.channelLogin,
|
||||
radius: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: Icon(
|
||||
Icons.delete_outline_rounded,
|
||||
size: 18,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: Text(
|
||||
'Remove',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
chatTabsStore.removeTab(index);
|
||||
},
|
||||
),
|
||||
],
|
||||
builder: (context, controller, child) {
|
||||
return InputChip(
|
||||
avatar: isActivated ? avatar : Opacity(opacity: 0.5, child: avatar),
|
||||
label: Text(
|
||||
displayName,
|
||||
style: isActivated
|
||||
? null
|
||||
: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.color?.withValues(alpha: 0.5),
|
||||
),
|
||||
style: dimmed
|
||||
? TextStyle(
|
||||
color: theme.textTheme.bodyMedium?.color?.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
selected: isActive,
|
||||
showCheckmark: false,
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
onPressed: () {
|
||||
if (!isActive) {
|
||||
HapticFeedback.selectionClick();
|
||||
chatTabsStore.setActiveTab(index);
|
||||
}
|
||||
if (isActive) return;
|
||||
HapticFeedback.selectionClick();
|
||||
chatTabsStore.setActiveTab(index);
|
||||
},
|
||||
onDeleted: () => controller.open(),
|
||||
deleteButtonTooltipMessage: 'Tab options',
|
||||
onDeleted: onDeleted,
|
||||
deleteButtonTooltipMessage: onDeleted != null ? 'Tab options' : null,
|
||||
);
|
||||
|
||||
if (!isSecondary) return buildChip();
|
||||
|
||||
return _AnchoredPopupMenu(
|
||||
itemsBuilder: (close) => [
|
||||
if (isActivated)
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(Icons.power_off_rounded, size: 18),
|
||||
child: const Text('Disconnect'),
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
close();
|
||||
chatTabsStore.deactivateTab(index);
|
||||
},
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: Icon(
|
||||
Icons.delete_outline_rounded,
|
||||
size: 18,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
child: Text(
|
||||
'Remove',
|
||||
style: TextStyle(color: theme.colorScheme.error),
|
||||
),
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
close();
|
||||
chatTabsStore.removeTab(index);
|
||||
},
|
||||
),
|
||||
],
|
||||
anchorBuilder: (context, toggle) => buildChip(onDeleted: toggle),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight popup menu anchored to a child widget. The menu's top-right
|
||||
/// aligns to the anchor's bottom-right via [CompositedTransformFollower] and
|
||||
/// fades + scales in from that corner. [MenuAnchor] doesn't expose an entry
|
||||
/// animation hook in this Flutter version, so we drive one ourselves.
|
||||
class _AnchoredPopupMenu extends StatefulWidget {
|
||||
final Widget Function(BuildContext context, VoidCallback toggle)
|
||||
anchorBuilder;
|
||||
final List<Widget> Function(VoidCallback close) itemsBuilder;
|
||||
|
||||
const _AnchoredPopupMenu({
|
||||
required this.anchorBuilder,
|
||||
required this.itemsBuilder,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_AnchoredPopupMenu> createState() => _AnchoredPopupMenuState();
|
||||
}
|
||||
|
||||
class _AnchoredPopupMenuState extends State<_AnchoredPopupMenu>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final _link = LayerLink();
|
||||
final _portalCtrl = OverlayPortalController();
|
||||
late final _anim =
|
||||
AnimationController(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
reverseDuration: const Duration(milliseconds: 100),
|
||||
vsync: this,
|
||||
)..addStatusListener((status) {
|
||||
if (status == AnimationStatus.dismissed && _portalCtrl.isShowing) {
|
||||
_portalCtrl.hide();
|
||||
}
|
||||
});
|
||||
late final _curve = CurvedAnimation(parent: _anim, curve: Curves.easeOut);
|
||||
late final _scale = Tween<double>(begin: 0.92, end: 1.0).animate(_curve);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_curve.dispose();
|
||||
_anim.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggle() {
|
||||
if (_portalCtrl.isShowing) {
|
||||
_anim.reverse();
|
||||
} else {
|
||||
_portalCtrl.show();
|
||||
_anim.forward();
|
||||
}
|
||||
}
|
||||
|
||||
void _close() {
|
||||
if (_portalCtrl.isShowing) _anim.reverse();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CompositedTransformTarget(
|
||||
link: _link,
|
||||
child: OverlayPortal(
|
||||
controller: _portalCtrl,
|
||||
overlayChildBuilder: (overlayContext) {
|
||||
final theme = Theme.of(overlayContext);
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _close,
|
||||
),
|
||||
),
|
||||
CompositedTransformFollower(
|
||||
link: _link,
|
||||
targetAnchor: Alignment.bottomRight,
|
||||
followerAnchor: Alignment.topRight,
|
||||
offset: const Offset(0, 4),
|
||||
showWhenUnlinked: false,
|
||||
child: FadeTransition(
|
||||
opacity: _anim,
|
||||
child: ScaleTransition(
|
||||
scale: _scale,
|
||||
alignment: Alignment.topRight,
|
||||
child: Material(
|
||||
color: theme.colorScheme.surface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(12),
|
||||
),
|
||||
side: BorderSide(
|
||||
color: theme.colorScheme.outlineVariant.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: IntrinsicWidth(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: widget.itemsBuilder(_close),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
child: widget.anchorBuilder(context, _toggle),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ class ChatUserModal extends StatefulWidget {
|
|||
final String username;
|
||||
final String displayName;
|
||||
final String userId;
|
||||
final VoidCallback? onActivateSourceTab;
|
||||
|
||||
const ChatUserModal({
|
||||
super.key,
|
||||
|
|
@ -21,6 +22,7 @@ class ChatUserModal extends StatefulWidget {
|
|||
required this.username,
|
||||
required this.displayName,
|
||||
required this.userId,
|
||||
this.onActivateSourceTab,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -61,6 +63,7 @@ class _ChatUserModalState extends State<ChatUserModal> {
|
|||
IconButton(
|
||||
tooltip: 'Reply',
|
||||
onPressed: () {
|
||||
widget.onActivateSourceTab?.call();
|
||||
widget.chatStore.textController.text =
|
||||
'@${widget.username} ';
|
||||
Navigator.pop(context);
|
||||
|
|
@ -115,6 +118,7 @@ class _ChatUserModalState extends State<ChatUserModal> {
|
|||
ircMessage: userMessages[index],
|
||||
chatStore: widget.chatStore,
|
||||
isModal: true,
|
||||
forceTimestamp: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
32
lib/screens/channel/video/chat_latency_sync.dart
Normal file
32
lib/screens/channel/video/chat_latency_sync.dart
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
|
||||
/// Single owner of `SettingsStore.syncedChatDelay` writes from the video
|
||||
/// player stores. Previously both stores poked the setting directly,
|
||||
/// making settings an ad-hoc event bus between video and chat.
|
||||
class ChatLatencySync {
|
||||
ChatLatencySync({required this.settingsStore});
|
||||
|
||||
final SettingsStore settingsStore;
|
||||
|
||||
/// Reset the delay to zero on stream transitions (channel enter, stream
|
||||
/// end, chat-only toggle, etc). No-op when auto-sync is disabled.
|
||||
void reset() {
|
||||
if (settingsStore.autoSyncChatDelay) {
|
||||
settingsStore.syncedChatDelay = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a fresh video-latency measurement. Only writes when drift from
|
||||
/// the current value exceeds the tolerance, to avoid thrashing the chat
|
||||
/// delay countdown on minor fluctuations.
|
||||
void report(double latencySeconds) {
|
||||
if (!settingsStore.autoSyncChatDelay) return;
|
||||
final current = settingsStore.syncedChatDelay;
|
||||
if (current == 0 ||
|
||||
(latencySeconds - current).abs() >
|
||||
VideoTimingConstants.chatSyncDriftToleranceSeconds) {
|
||||
settingsStore.syncedChatDelay = latencySeconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
86
lib/screens/channel/video/stream_info_poller.dart
Normal file
86
lib/screens/channel/video/stream_info_poller.dart
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import 'package:frosty/apis/twitch_api.dart';
|
||||
import 'package:frosty/models/channel.dart';
|
||||
import 'package:frosty/models/stream.dart';
|
||||
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
|
||||
|
||||
/// Snapshot of a channel's live state.
|
||||
class StreamInfoResult {
|
||||
const StreamInfoResult({this.stream, this.offlineChannel});
|
||||
|
||||
/// Non-null when the channel is currently live.
|
||||
final StreamTwitch? stream;
|
||||
|
||||
/// Non-null when the channel is confirmed offline (channel metadata fetched
|
||||
/// successfully but no live stream). Both fields null means the fetch
|
||||
/// failed entirely (network error, etc.).
|
||||
final Channel? offlineChannel;
|
||||
}
|
||||
|
||||
/// Debounced, deduplicated Twitch stream-info fetcher shared by both video
|
||||
/// stores. Each store still owns its observable `_streamInfo` /
|
||||
/// `_offlineChannelInfo` fields and decides how to react to the result.
|
||||
class StreamInfoPoller {
|
||||
StreamInfoPoller({
|
||||
required this.twitchApi,
|
||||
required this.userLogin,
|
||||
required this.userId,
|
||||
});
|
||||
|
||||
final TwitchApi twitchApi;
|
||||
final String userLogin;
|
||||
final String userId;
|
||||
|
||||
DateTime? _lastUpdate;
|
||||
Future<StreamInfoResult>? _inFlight;
|
||||
|
||||
/// Fetches the latest stream info, returning `null` if the call was
|
||||
/// debounced (caller should skip updating state). Concurrent calls await
|
||||
/// the in-flight request instead of issuing a new one.
|
||||
Future<StreamInfoResult?> fetch({bool forceUpdate = false}) async {
|
||||
final pending = _inFlight;
|
||||
if (pending != null) return pending;
|
||||
|
||||
final now = DateTime.now();
|
||||
if (!forceUpdate && _lastUpdate != null) {
|
||||
if (now.difference(_lastUpdate!) <
|
||||
VideoTimingConstants.streamInfoDebounce) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
_lastUpdate = now;
|
||||
final request = _runFetch();
|
||||
_inFlight = request;
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
if (identical(_inFlight, request)) _inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<StreamInfoResult> _runFetch() async {
|
||||
try {
|
||||
final info = await twitchApi.getStream(userLogin: userLogin);
|
||||
return StreamInfoResult(stream: info);
|
||||
} catch (_) {
|
||||
// Helix can transiently drop a live stream from /streams (or 500 on
|
||||
// one endpoint while others succeed). A false "confirmed offline"
|
||||
// tears down latency polling and chat delay on healthy playback, so
|
||||
// require a second failed check before falling back to /channels.
|
||||
try {
|
||||
await Future<void>.delayed(
|
||||
VideoTimingConstants.offlineConfirmationDelay,
|
||||
);
|
||||
final info = await twitchApi.getStream(userLogin: userLogin);
|
||||
return StreamInfoResult(stream: info);
|
||||
} catch (_) {
|
||||
try {
|
||||
final channel = await twitchApi.getChannel(userId: userId);
|
||||
return StreamInfoResult(offlineChannel: channel);
|
||||
} catch (_) {
|
||||
return const StreamInfoResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:frosty/constants.dart';
|
||||
import 'package:frosty/screens/channel/chat/details/chat_users_list.dart';
|
||||
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
||||
import 'package:frosty/screens/channel/video/cast_button.dart';
|
||||
|
|
@ -88,7 +89,7 @@ class VideoOverlay extends StatelessWidget {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SectionHeader(
|
||||
'Stream quality',
|
||||
'Quality',
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
isFirst: true,
|
||||
),
|
||||
|
|
@ -108,7 +109,7 @@ class VideoOverlay extends StatelessWidget {
|
|||
videoStore.setStreamQuality(quality);
|
||||
SharedPreferences.getInstance().then(
|
||||
(prefs) => prefs.setString(
|
||||
'last_stream_quality',
|
||||
lastStreamQualityKey(videoStore.userLogin),
|
||||
quality,
|
||||
),
|
||||
);
|
||||
|
|
@ -343,33 +344,14 @@ class VideoOverlay extends StatelessWidget {
|
|||
videoSettingsButton,
|
||||
],
|
||||
),
|
||||
Center(
|
||||
child: ValueListenableBuilder<CastState>(
|
||||
valueListenable: StreamProxyBridge.castState,
|
||||
builder: (context, castState, _) {
|
||||
if (castState.isCasting) {
|
||||
return CastStatusButton(
|
||||
castState: castState,
|
||||
color: surfaceColor,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: const Offset(0, 3),
|
||||
blurRadius: 8,
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Tooltip(
|
||||
message: videoStore.paused ? 'Play' : 'Pause',
|
||||
preferBelow: false,
|
||||
child: IconButton(
|
||||
iconSize: 56,
|
||||
icon: Icon(
|
||||
videoStore.paused
|
||||
? Icons.play_arrow_rounded
|
||||
: Icons.pause_rounded,
|
||||
if (!videoStore.loading)
|
||||
Center(
|
||||
child: ValueListenableBuilder<CastState>(
|
||||
valueListenable: StreamProxyBridge.castState,
|
||||
builder: (context, castState, _) {
|
||||
if (castState.isCasting) {
|
||||
return CastStatusButton(
|
||||
castState: castState,
|
||||
color: surfaceColor,
|
||||
shadows: [
|
||||
Shadow(
|
||||
|
|
@ -378,13 +360,33 @@ class VideoOverlay extends StatelessWidget {
|
|||
color: Colors.black.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Tooltip(
|
||||
message: videoStore.paused ? 'Play' : 'Pause',
|
||||
preferBelow: false,
|
||||
child: IconButton(
|
||||
iconSize: 56,
|
||||
icon: Icon(
|
||||
videoStore.paused
|
||||
? Icons.play_arrow_rounded
|
||||
: Icons.pause_rounded,
|
||||
color: surfaceColor,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: const Offset(0, 3),
|
||||
blurRadius: 8,
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
onPressed: videoStore.handlePausePlay,
|
||||
),
|
||||
onPressed: videoStore.handlePausePlay,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: Row(
|
||||
|
|
|
|||
|
|
@ -5,8 +5,12 @@ import 'dart:io';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:frosty/apis/twitch_api.dart';
|
||||
import 'package:frosty/constants.dart';
|
||||
import 'package:frosty/models/channel.dart';
|
||||
import 'package:frosty/models/stream.dart';
|
||||
import 'package:frosty/screens/channel/video/chat_latency_sync.dart';
|
||||
import 'package:frosty/screens/channel/video/stream_info_poller.dart';
|
||||
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
import 'package:frosty/services/stream_proxy_bridge.dart';
|
||||
|
|
@ -36,8 +40,17 @@ abstract class VideoStoreBase with Store {
|
|||
|
||||
/// The [SimplePip] instance used for initiating PiP on Android.
|
||||
final pip = SimplePip();
|
||||
late final ChatLatencySync _chatLatencySync = ChatLatencySync(
|
||||
settingsStore: settingsStore,
|
||||
);
|
||||
late final StreamInfoPoller _streamInfoPoller = StreamInfoPoller(
|
||||
twitchApi: twitchApi,
|
||||
userLogin: userLogin,
|
||||
userId: userId,
|
||||
);
|
||||
|
||||
var _firstTimeSettingQuality = true;
|
||||
var _highLatencyCount = 0;
|
||||
|
||||
/// Whether [initVideo] should run on the next [onPageFinished].
|
||||
///
|
||||
|
|
@ -71,7 +84,49 @@ abstract class VideoStoreBase with Store {
|
|||
if (!settingsStore.autoSyncChatDelay) return;
|
||||
if (StreamProxyBridge.castState.value.isCasting) return;
|
||||
|
||||
_syncChatDelayFromLatencyText(receivedLatency);
|
||||
final numericPart = receivedLatency.replaceAll(
|
||||
RegExp(r'[^0-9.]'),
|
||||
'',
|
||||
);
|
||||
final latencyAsDouble = double.tryParse(numericPart);
|
||||
|
||||
if (latencyAsDouble != null &&
|
||||
latencyAsDouble >=
|
||||
VideoTimingConstants.highLatencyThresholdSeconds &&
|
||||
!_paused &&
|
||||
!_loading &&
|
||||
_highLatencyCount <
|
||||
VideoTimingConstants.maxHighLatencyRecoveries) {
|
||||
_highLatencyCount++;
|
||||
if (_highLatencyCount == 1) {
|
||||
debugPrint(
|
||||
'VideoStore: high latency (${latencyAsDouble.round()}s), seeking to live edge',
|
||||
);
|
||||
try {
|
||||
videoWebViewController.runJavaScript('''
|
||||
(function() {
|
||||
const v = window._videoEl || document.querySelector("video");
|
||||
if (v && v.buffered.length > 0) {
|
||||
v.currentTime = v.buffered.end(v.buffered.length - 1);
|
||||
}
|
||||
})();
|
||||
''');
|
||||
} catch (e) {
|
||||
debugPrint(e.toString());
|
||||
}
|
||||
} else {
|
||||
debugPrint(
|
||||
'VideoStore: latency still high (${latencyAsDouble.round()}s), refreshing',
|
||||
);
|
||||
handleRefresh(haptic: false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (latencyAsDouble != null) {
|
||||
_highLatencyCount = 0;
|
||||
_chatLatencySync.report(latencyAsDouble);
|
||||
}
|
||||
},
|
||||
)
|
||||
..addJavaScriptChannel(
|
||||
|
|
@ -88,7 +143,9 @@ abstract class VideoStoreBase with Store {
|
|||
return;
|
||||
}
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final lastStreamQuality = prefs.getString('last_stream_quality');
|
||||
final lastStreamQuality = prefs.getString(
|
||||
lastStreamQualityKey(userLogin),
|
||||
);
|
||||
if (lastStreamQuality == null) {
|
||||
await _updateCastContext();
|
||||
return;
|
||||
|
|
@ -108,6 +165,7 @@ abstract class VideoStoreBase with Store {
|
|||
..addJavaScriptChannel(
|
||||
'VideoPlaying',
|
||||
onMessageReceived: (message) {
|
||||
_loading = false;
|
||||
_paused = false;
|
||||
if (Platform.isAndroid) pip.setIsPlaying(true);
|
||||
},
|
||||
|
|
@ -148,12 +206,6 @@ abstract class VideoStoreBase with Store {
|
|||
/// Timer for periodic JavaScript state cleanup to prevent memory accumulation.
|
||||
Timer? _jsCleanupTimer;
|
||||
|
||||
/// Tracks the last time stream info was updated to prevent double refresh
|
||||
DateTime? _lastStreamInfoUpdate;
|
||||
|
||||
/// Tracks an in-flight stream info request so refreshes can be deduplicated.
|
||||
Future<void>? _streamInfoRequest;
|
||||
|
||||
/// Disposes the overlay reactions.
|
||||
late final ReactionDisposer _disposeOverlayReaction;
|
||||
|
||||
|
|
@ -168,6 +220,9 @@ abstract class VideoStoreBase with Store {
|
|||
/// Disposes the stream proxy settings reaction.
|
||||
ReactionDisposer? _disposeStreamProxyReaction;
|
||||
|
||||
@readonly
|
||||
var _loading = true;
|
||||
|
||||
/// If the video is currently paused.
|
||||
///
|
||||
/// Does not pause or play the video, only used for rendering state of the overlay.
|
||||
|
|
@ -222,10 +277,7 @@ abstract class VideoStoreBase with Store {
|
|||
required this.authStore,
|
||||
required this.settingsStore,
|
||||
}) {
|
||||
// Reset chat delay to 0 if auto sync is already enabled to prevent starting with old values
|
||||
if (settingsStore.autoSyncChatDelay) {
|
||||
settingsStore.syncedChatDelay = 0.0;
|
||||
}
|
||||
_chatLatencySync.reset();
|
||||
// Initialize the video webview params for Android to enable video autoplay.
|
||||
if (videoWebViewController.platform is AndroidWebViewController) {
|
||||
(videoWebViewController.platform as AndroidWebViewController)
|
||||
|
|
@ -248,13 +300,7 @@ abstract class VideoStoreBase with Store {
|
|||
await updateStreamQualities();
|
||||
} else {
|
||||
_showDefaultOverlay();
|
||||
try {
|
||||
videoWebViewController.runJavaScript(
|
||||
'window._latencyTracker?.stop()',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint(e.toString());
|
||||
}
|
||||
_stopLatencyTracker();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -272,6 +318,7 @@ abstract class VideoStoreBase with Store {
|
|||
_stopJsCleanupTimer();
|
||||
// Ensure overlay timer is active for clean UI
|
||||
_scheduleOverlayHide();
|
||||
_stopLatencyTracker();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -314,8 +361,7 @@ abstract class VideoStoreBase with Store {
|
|||
(_) => (settingsStore.showLatency, settingsStore.autoSyncChatDelay),
|
||||
(values) async {
|
||||
final (showLatency, autoSync) = values;
|
||||
// Only act if overlay is enabled (latency tracker only works with custom overlay)
|
||||
if (!settingsStore.showOverlay) return;
|
||||
if (!settingsStore.showVideo || !settingsStore.showOverlay) return;
|
||||
|
||||
if (showLatency || autoSync) {
|
||||
// Start tracker if either setting is now enabled
|
||||
|
|
@ -332,13 +378,7 @@ abstract class VideoStoreBase with Store {
|
|||
}
|
||||
} else {
|
||||
// Stop tracker if both settings are now disabled
|
||||
try {
|
||||
videoWebViewController.runJavaScript(
|
||||
'window._latencyTracker?.stop()',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint(e.toString());
|
||||
}
|
||||
_stopLatencyTracker();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -385,8 +425,9 @@ abstract class VideoStoreBase with Store {
|
|||
final castLatency = castState.latency;
|
||||
if (castState.isCasting) {
|
||||
if (castLatency != null) {
|
||||
settingsStore.syncedChatDelay = (castLatency.inMilliseconds / 1000)
|
||||
.ceilToDouble();
|
||||
_chatLatencySync.report(
|
||||
(castLatency.inMilliseconds / 1000).ceilToDouble(),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -403,7 +444,7 @@ abstract class VideoStoreBase with Store {
|
|||
final latencyAsDouble = double.tryParse(numericPart);
|
||||
|
||||
if (latencyAsDouble != null) {
|
||||
settingsStore.syncedChatDelay = latencyAsDouble;
|
||||
_chatLatencySync.report(latencyAsDouble);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -741,7 +782,7 @@ abstract class VideoStoreBase with Store {
|
|||
// Convert to abbreviated unit: "4.69 sec." -> "4.69s"
|
||||
const match = latencyText.match(/([0-9.]+)\s*sec/i);
|
||||
if (match) {
|
||||
latencyText = parseFloat(match[1]).toFixed(2) + 's';
|
||||
latencyText = Math.round(parseFloat(match[1])) + 's';
|
||||
this.hasInitialLatency = true;
|
||||
}
|
||||
|
||||
|
|
@ -987,7 +1028,7 @@ abstract class VideoStoreBase with Store {
|
|||
// Only start if not already active
|
||||
if (_streamInfoTimer?.isActive != true) {
|
||||
_streamInfoTimer = Timer.periodic(
|
||||
const Duration(seconds: 60),
|
||||
VideoTimingConstants.webviewStreamInfoInterval,
|
||||
(_) => updateStreamInfo(),
|
||||
);
|
||||
}
|
||||
|
|
@ -1005,7 +1046,7 @@ abstract class VideoStoreBase with Store {
|
|||
if (_jsCleanupTimer?.isActive == true) return;
|
||||
|
||||
_jsCleanupTimer = Timer.periodic(
|
||||
const Duration(minutes: 10),
|
||||
VideoTimingConstants.jsCleanupInterval,
|
||||
(_) => _performJsSoftReset(),
|
||||
);
|
||||
}
|
||||
|
|
@ -1015,7 +1056,9 @@ abstract class VideoStoreBase with Store {
|
|||
_jsCleanupTimer = null;
|
||||
}
|
||||
|
||||
void _scheduleOverlayHide([Duration delay = const Duration(seconds: 5)]) {
|
||||
void _scheduleOverlayHide([
|
||||
Duration delay = VideoTimingConstants.overlayAutoHide,
|
||||
]) {
|
||||
_overlayTimer?.cancel();
|
||||
|
||||
if (_isInPipMode) {
|
||||
|
|
@ -1049,58 +1092,25 @@ abstract class VideoStoreBase with Store {
|
|||
/// Set [forceUpdate] to true to bypass the rate limiting check.
|
||||
@action
|
||||
Future<void> updateStreamInfo({bool forceUpdate = false}) async {
|
||||
if (_streamInfoRequest != null) {
|
||||
await _streamInfoRequest;
|
||||
return;
|
||||
}
|
||||
|
||||
// Rate limiting: prevent too frequent updates unless forced
|
||||
final now = DateTime.now();
|
||||
if (!forceUpdate && _lastStreamInfoUpdate != null) {
|
||||
final timeSinceLastUpdate = now.difference(_lastStreamInfoUpdate!);
|
||||
if (timeSinceLastUpdate.inSeconds < 5) {
|
||||
return; // Skip update if less than 5 seconds since last update
|
||||
final result = await _streamInfoPoller.fetch(forceUpdate: forceUpdate);
|
||||
if (result == null) return;
|
||||
runInAction(() {
|
||||
if (result.stream != null) {
|
||||
_streamInfo = result.stream;
|
||||
_offlineChannelInfo = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_lastStreamInfoUpdate = now;
|
||||
|
||||
final request = _updateStreamInfoInternal();
|
||||
_streamInfoRequest = request;
|
||||
|
||||
try {
|
||||
await request;
|
||||
} finally {
|
||||
if (identical(_streamInfoRequest, request)) {
|
||||
_streamInfoRequest = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updateStreamInfoInternal() async {
|
||||
try {
|
||||
_streamInfo = await twitchApi.getStream(userLogin: userLogin);
|
||||
// Clear offline info when stream is live
|
||||
_offlineChannelInfo = null;
|
||||
} catch (e) {
|
||||
_overlayTimer?.cancel();
|
||||
_loading = false;
|
||||
_streamInfo = null;
|
||||
_paused = true;
|
||||
|
||||
// Try to fetch offline channel information
|
||||
try {
|
||||
_offlineChannelInfo = await twitchApi.getChannel(userId: userId);
|
||||
} catch (channelError) {
|
||||
_offlineChannelInfo = null;
|
||||
}
|
||||
|
||||
_offlineChannelInfo = result.offlineChannel;
|
||||
// Restart overlay timer in chat-only mode even on error
|
||||
if (!settingsStore.showVideo) {
|
||||
_scheduleOverlayHide();
|
||||
}
|
||||
} finally {
|
||||
await _updateCastContext();
|
||||
}
|
||||
});
|
||||
await _updateCastContext();
|
||||
}
|
||||
|
||||
/// Handles the toggle overlay options.
|
||||
|
|
@ -1115,7 +1125,7 @@ abstract class VideoStoreBase with Store {
|
|||
|
||||
if (settingsStore.showOverlay) {
|
||||
_overlayVisible = true;
|
||||
_scheduleOverlayHide(const Duration(seconds: 3));
|
||||
_scheduleOverlayHide(VideoTimingConstants.overlayQuickHide);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1131,7 +1141,9 @@ abstract class VideoStoreBase with Store {
|
|||
HapticFeedback.lightImpact();
|
||||
}
|
||||
_paused = true;
|
||||
_loading = true;
|
||||
_firstTimeSettingQuality = true;
|
||||
_highLatencyCount = 0;
|
||||
_isInPipMode = false;
|
||||
_latency = null;
|
||||
_availableStreamQualities = [];
|
||||
|
|
@ -1139,11 +1151,7 @@ abstract class VideoStoreBase with Store {
|
|||
// Signal that initVideo() should run on the next onPageFinished
|
||||
_needsInit = true;
|
||||
|
||||
try {
|
||||
videoWebViewController.runJavaScript('window._latencyTracker?.stop()');
|
||||
} catch (e) {
|
||||
// Ignore - may not exist yet
|
||||
}
|
||||
_stopLatencyTracker();
|
||||
|
||||
await _syncStreamProxyBridge(refresh: false);
|
||||
await videoWebViewController.loadRequest(Uri.parse(videoUrl));
|
||||
|
|
@ -1231,10 +1239,14 @@ abstract class VideoStoreBase with Store {
|
|||
// Explicitly stop latency tracker as defense-in-depth.
|
||||
// The Video widget also loads about:blank during disposal which clears
|
||||
// all JavaScript state, but this provides an extra safety layer.
|
||||
_stopLatencyTracker();
|
||||
}
|
||||
|
||||
void _stopLatencyTracker() {
|
||||
try {
|
||||
videoWebViewController.runJavaScript('window._latencyTracker?.stop()');
|
||||
} catch (e) {
|
||||
// Ignore - page may already be unloading
|
||||
debugPrint(e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
31
lib/screens/channel/video/video_timing_constants.dart
Normal file
31
lib/screens/channel/video/video_timing_constants.dart
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/// Named durations and counts used by the video player stores.
|
||||
/// Central owner so tuning values don't have to be hunted across files.
|
||||
class VideoTimingConstants {
|
||||
VideoTimingConstants._();
|
||||
|
||||
// Latency / chat sync
|
||||
static const int highLatencyThresholdSeconds = 30;
|
||||
static const int chatSyncDriftToleranceSeconds = 2;
|
||||
|
||||
/// Maximum consecutive high-latency interventions (seek/refresh) before
|
||||
/// giving up on latency-driven recovery. Persistent high readings usually
|
||||
/// mean device clock skew or a device that can't keep up; looping
|
||||
/// recoveries forever makes playback worse, not better.
|
||||
static const int maxHighLatencyRecoveries = 3;
|
||||
|
||||
/// Delay before re-checking /streams when the first check reports offline.
|
||||
/// Helix transiently drops live streams from results; a single failed
|
||||
/// check isn't enough to tear down healthy playback.
|
||||
static const offlineConfirmationDelay = Duration(seconds: 2);
|
||||
|
||||
// Overlay
|
||||
static const overlayAutoHide = Duration(seconds: 5);
|
||||
static const overlayQuickHide = Duration(seconds: 3);
|
||||
|
||||
// Stream info
|
||||
static const streamInfoDebounce = Duration(seconds: 5);
|
||||
static const webviewStreamInfoInterval = Duration(minutes: 1);
|
||||
|
||||
// WebView housekeeping
|
||||
static const jsCleanupInterval = Duration(minutes: 10);
|
||||
}
|
||||
|
|
@ -166,13 +166,13 @@ class _HomeState extends State<Home> {
|
|||
NavigationDestination(
|
||||
icon: Icon(
|
||||
Icons.favorite_border_rounded,
|
||||
color: _homeStore.selectedIndex == 0
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
color: theme.colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
),
|
||||
selectedIcon: Icon(
|
||||
Icons.favorite_rounded,
|
||||
color: theme.colorScheme.onSurface,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
label: 'Following',
|
||||
tooltip: 'Following',
|
||||
|
|
@ -180,15 +180,13 @@ class _HomeState extends State<Home> {
|
|||
NavigationDestination(
|
||||
icon: Icon(
|
||||
Icons.arrow_upward_rounded,
|
||||
color:
|
||||
_homeStore.selectedIndex ==
|
||||
(_authStore.isLoggedIn ? 1 : 0)
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
color: theme.colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
),
|
||||
selectedIcon: Icon(
|
||||
Icons.arrow_upward_rounded,
|
||||
color: theme.colorScheme.onSurface,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
label: 'Top',
|
||||
tooltip: 'Top',
|
||||
|
|
@ -196,15 +194,13 @@ class _HomeState extends State<Home> {
|
|||
NavigationDestination(
|
||||
icon: Icon(
|
||||
Icons.search_rounded,
|
||||
color:
|
||||
_homeStore.selectedIndex ==
|
||||
(_authStore.isLoggedIn ? 2 : 1)
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
color: theme.colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
),
|
||||
selectedIcon: Icon(
|
||||
Icons.search_rounded,
|
||||
color: theme.colorScheme.onSurface,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
label: 'Search',
|
||||
tooltip: 'Search',
|
||||
|
|
|
|||
|
|
@ -13,26 +13,63 @@ import 'package:frosty/widgets/section_header.dart';
|
|||
import 'package:frosty/widgets/settings_page_layout.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class ChatSettings extends StatefulWidget {
|
||||
class ChatSettings extends StatelessWidget {
|
||||
final SettingsStore settingsStore;
|
||||
|
||||
const ChatSettings({super.key, required this.settingsStore});
|
||||
|
||||
@override
|
||||
State<ChatSettings> createState() => _ChatSettingsState();
|
||||
}
|
||||
|
||||
class _ChatSettingsState extends State<ChatSettings> {
|
||||
var showPreview = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settingsStore = widget.settingsStore;
|
||||
|
||||
return Observer(
|
||||
builder: (context) => SettingsPageLayout(
|
||||
children: [
|
||||
const SectionHeader('Message sizing', isFirst: true),
|
||||
const SectionHeader('General', isFirst: true),
|
||||
SettingsListSwitch(
|
||||
title: 'Keep screen on',
|
||||
subtitle: const Text(
|
||||
'Prevents the screen from sleeping while a channel is open.',
|
||||
),
|
||||
value: settingsStore.keepScreenAwake,
|
||||
onChanged: (newValue) => settingsStore.keepScreenAwake = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Autocomplete',
|
||||
subtitle: const Text(
|
||||
'Shows matching emotes and mentions while typing.',
|
||||
),
|
||||
value: settingsStore.autocomplete,
|
||||
onChanged: (newValue) => settingsStore.autocomplete = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Load recent messages',
|
||||
subtitle: Text.rich(
|
||||
TextSpan(
|
||||
text:
|
||||
'Loads historical recent messages in chat through a third-party API service at ',
|
||||
children: [
|
||||
TextSpan(
|
||||
text: 'https://recent-messages.robotty.de/',
|
||||
style: TextStyle(
|
||||
color: context.colorScheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: context.colorScheme.primary,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () => launchUrl(
|
||||
Uri.parse('https://recent-messages.robotty.de/'),
|
||||
mode: settingsStore.launchUrlExternal
|
||||
? LaunchMode.externalApplication
|
||||
: LaunchMode.inAppBrowserView,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
value: settingsStore.showRecentMessages,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.showRecentMessages = newValue,
|
||||
),
|
||||
const SectionHeader('Message sizing'),
|
||||
ExpansionTile(
|
||||
title: const Text('Preview'),
|
||||
children: [
|
||||
|
|
@ -153,21 +190,41 @@ class _ChatSettingsState extends State<ChatSettings> {
|
|||
settingsStore.showChatMessageDividers = newValue,
|
||||
),
|
||||
SettingsListSelect(
|
||||
title: 'Message timestamps',
|
||||
title: 'Timestamps',
|
||||
selectedOption: timestampNames[settingsStore.timestampType.index],
|
||||
options: timestampNames,
|
||||
onChanged: (newValue) => settingsStore.timestampType =
|
||||
TimestampType.values[timestampNames.indexOf(newValue)],
|
||||
),
|
||||
const SectionHeader('Delay and latency'),
|
||||
SettingsListSwitch(
|
||||
title: 'Sync message delay and stream latency',
|
||||
title: 'Show timestamps on historical messages',
|
||||
subtitle: const Text(
|
||||
'Always show timestamps on messages loaded from chat history, '
|
||||
'even when timestamps are disabled above.',
|
||||
),
|
||||
value: settingsStore.showHistoricalTimestamps,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.showHistoricalTimestamps = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Focus current channel',
|
||||
subtitle: const Text(
|
||||
'Fades messages from other channels in shared chat and '
|
||||
'merged views, so the current channel stands out.',
|
||||
),
|
||||
value: settingsStore.focusCurrentChannel,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.focusCurrentChannel = newValue,
|
||||
),
|
||||
const SectionHeader('Delay'),
|
||||
SettingsListSwitch(
|
||||
title: 'Auto-sync chat delay',
|
||||
value: settingsStore.autoSyncChatDelay,
|
||||
onChanged: (newValue) => settingsStore.autoSyncChatDelay = newValue,
|
||||
),
|
||||
if (!settingsStore.autoSyncChatDelay)
|
||||
SettingsListSlider(
|
||||
title: 'Message delay',
|
||||
title: 'Chat delay',
|
||||
trailing: '${settingsStore.chatDelay.toInt()} seconds',
|
||||
subtitle: 'Adds a delay before each message is rendered in chat.',
|
||||
value: settingsStore.chatDelay,
|
||||
|
|
@ -177,7 +234,7 @@ class _ChatSettingsState extends State<ChatSettings> {
|
|||
),
|
||||
const SectionHeader('Alerts'),
|
||||
SettingsListSwitch(
|
||||
title: 'Highlight first time chatters',
|
||||
title: 'Highlight first-time chatters',
|
||||
value: settingsStore.highlightFirstTimeChatter,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.highlightFirstTimeChatter = newValue,
|
||||
|
|
@ -185,25 +242,25 @@ class _ChatSettingsState extends State<ChatSettings> {
|
|||
SettingsListSwitch(
|
||||
title: 'Show notices',
|
||||
subtitle: const Text(
|
||||
'Shows notices such as subs and re-subs, announcements, and raids.',
|
||||
'Shows notices such as subs, announcements, and raids.',
|
||||
),
|
||||
value: settingsStore.showUserNotices,
|
||||
onChanged: (newValue) => settingsStore.showUserNotices = newValue,
|
||||
),
|
||||
const SectionHeader('Layout'),
|
||||
SettingsListSwitch(
|
||||
title: 'Move emote menu button left',
|
||||
title: 'Emote menu on left',
|
||||
subtitle: const Text(
|
||||
'Places the emote menu button on the left side to avoid accidental presses.',
|
||||
'Places the emote menu button on the left side.',
|
||||
),
|
||||
value: settingsStore.emoteMenuButtonOnLeft,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.emoteMenuButtonOnLeft = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Persist chat tabs',
|
||||
title: 'Remember chat tabs',
|
||||
subtitle: const Text(
|
||||
'Secondary chat tabs are remembered when switching channels.',
|
||||
'Secondary chat tabs are kept when switching channels.',
|
||||
),
|
||||
value: settingsStore.persistChatTabs,
|
||||
onChanged: (newValue) {
|
||||
|
|
@ -213,26 +270,23 @@ class _ChatSettingsState extends State<ChatSettings> {
|
|||
}
|
||||
},
|
||||
),
|
||||
const SectionHeader('Landscape mode'),
|
||||
const SectionHeader('Landscape'),
|
||||
SettingsListSwitch(
|
||||
title: 'Move chat left',
|
||||
title: 'Chat on left side',
|
||||
value: settingsStore.landscapeChatLeftSide,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.landscapeChatLeftSide = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Force vertical chat',
|
||||
subtitle: const Text(
|
||||
'Intended for tablets and other larger displays.',
|
||||
),
|
||||
subtitle: const Text('Intended for tablets and larger displays.'),
|
||||
value: settingsStore.landscapeForceVerticalChat,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.landscapeForceVerticalChat = newValue,
|
||||
),
|
||||
SettingsListSelect(
|
||||
title: 'Fill notch side',
|
||||
subtitle:
|
||||
'Overrides and fills the available space in devices with a display notch.',
|
||||
title: 'Notch fill',
|
||||
subtitle: 'Fills the display cutout area on the selected side.',
|
||||
selectedOption:
|
||||
landscapeCutoutNames[settingsStore.landscapeCutout.index],
|
||||
options: landscapeCutoutNames,
|
||||
|
|
@ -242,17 +296,16 @@ class _ChatSettingsState extends State<ChatSettings> {
|
|||
)],
|
||||
),
|
||||
SettingsListSlider(
|
||||
title: 'Chat overlay opacity',
|
||||
title: 'Overlay chat opacity',
|
||||
trailing:
|
||||
'${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%',
|
||||
subtitle:
|
||||
'Sets the opacity (transparency) of the overlay chat in fullscreen mode.',
|
||||
subtitle: 'Opacity of the chat overlay in fullscreen mode.',
|
||||
value: settingsStore.fullScreenChatOverlayOpacity,
|
||||
divisions: 10,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.fullScreenChatOverlayOpacity = newValue,
|
||||
),
|
||||
const SectionHeader('Muted keywords'),
|
||||
const SectionHeader('Filtering'),
|
||||
SettingsMutedWords(settingsStore: settingsStore),
|
||||
SettingsListSwitch(
|
||||
title: 'Match whole words',
|
||||
|
|
@ -262,81 +315,42 @@ class _ChatSettingsState extends State<ChatSettings> {
|
|||
value: settingsStore.matchWholeWord,
|
||||
onChanged: (newValue) => settingsStore.matchWholeWord = newValue,
|
||||
),
|
||||
const SectionHeader('Autocomplete'),
|
||||
SettingsListSwitch(
|
||||
title: 'Show autocomplete bar',
|
||||
subtitle: const Text(
|
||||
'Shows a bar containing matching emotes and mentions while typing.',
|
||||
),
|
||||
value: settingsStore.autocomplete,
|
||||
onChanged: (newValue) => settingsStore.autocomplete = newValue,
|
||||
),
|
||||
const SectionHeader('Emotes and badges'),
|
||||
SettingsListSwitch(
|
||||
title: 'Show Twitch emotes',
|
||||
title: 'Twitch emotes',
|
||||
value: settingsStore.showTwitchEmotes,
|
||||
onChanged: (newValue) => settingsStore.showTwitchEmotes = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Show Twitch badges',
|
||||
title: 'Twitch badges',
|
||||
value: settingsStore.showTwitchBadges,
|
||||
onChanged: (newValue) => settingsStore.showTwitchBadges = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Show 7TV emotes',
|
||||
title: '7TV emotes',
|
||||
value: settingsStore.show7TVEmotes,
|
||||
onChanged: (newValue) => settingsStore.show7TVEmotes = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Show BTTV emotes',
|
||||
title: 'BTTV emotes',
|
||||
value: settingsStore.showBTTVEmotes,
|
||||
onChanged: (newValue) => settingsStore.showBTTVEmotes = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Show BTTV badges',
|
||||
title: 'BTTV badges',
|
||||
value: settingsStore.showBTTVBadges,
|
||||
onChanged: (newValue) => settingsStore.showBTTVBadges = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Show FFZ emotes',
|
||||
title: 'FFZ emotes',
|
||||
value: settingsStore.showFFZEmotes,
|
||||
onChanged: (newValue) => settingsStore.showFFZEmotes = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Show FFZ badges',
|
||||
title: 'FFZ badges',
|
||||
value: settingsStore.showFFZBadges,
|
||||
onChanged: (newValue) => settingsStore.showFFZBadges = newValue,
|
||||
),
|
||||
const SectionHeader('Recent messages'),
|
||||
SettingsListSwitch(
|
||||
title: 'Show historical recent messages',
|
||||
subtitle: Text.rich(
|
||||
TextSpan(
|
||||
text:
|
||||
'Loads historical recent messages in chat through a third-party API service at ',
|
||||
children: [
|
||||
TextSpan(
|
||||
text: 'https://recent-messages.robotty.de/',
|
||||
style: TextStyle(
|
||||
color: context.colorScheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: context.colorScheme.primary,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () => launchUrl(
|
||||
Uri.parse('https://recent-messages.robotty.de/'),
|
||||
mode: settingsStore.launchUrlExternal
|
||||
? LaunchMode.externalApplication
|
||||
: LaunchMode.inAppBrowserView,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
value: settingsStore.showRecentMessages,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.showRecentMessages = newValue,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -58,6 +58,9 @@ SettingsStore _$SettingsStoreFromJson(
|
|||
unknownValue: TimestampType.disabled,
|
||||
) ??
|
||||
TimestampType.disabled
|
||||
..showHistoricalTimestamps =
|
||||
json['showHistoricalTimestamps'] as bool? ?? false
|
||||
..focusCurrentChannel = json['focusCurrentChannel'] as bool? ?? false
|
||||
..autoSyncChatDelay = json['autoSyncChatDelay'] as bool? ?? false
|
||||
..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0
|
||||
..highlightFirstTimeChatter =
|
||||
|
|
@ -131,6 +134,8 @@ Map<String, dynamic> _$SettingsStoreToJson(
|
|||
'showDeletedMessages': instance.showDeletedMessages,
|
||||
'showChatMessageDividers': instance.showChatMessageDividers,
|
||||
'timestampType': _$TimestampTypeEnumMap[instance.timestampType]!,
|
||||
'showHistoricalTimestamps': instance.showHistoricalTimestamps,
|
||||
'focusCurrentChannel': instance.focusCurrentChannel,
|
||||
'autoSyncChatDelay': instance.autoSyncChatDelay,
|
||||
'chatDelay': instance.chatDelay,
|
||||
'highlightFirstTimeChatter': instance.highlightFirstTimeChatter,
|
||||
|
|
@ -625,6 +630,46 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
|
|||
});
|
||||
}
|
||||
|
||||
late final _$showHistoricalTimestampsAtom = Atom(
|
||||
name: '_SettingsStoreBase.showHistoricalTimestamps',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
bool get showHistoricalTimestamps {
|
||||
_$showHistoricalTimestampsAtom.reportRead();
|
||||
return super.showHistoricalTimestamps;
|
||||
}
|
||||
|
||||
@override
|
||||
set showHistoricalTimestamps(bool value) {
|
||||
_$showHistoricalTimestampsAtom.reportWrite(
|
||||
value,
|
||||
super.showHistoricalTimestamps,
|
||||
() {
|
||||
super.showHistoricalTimestamps = value;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
late final _$focusCurrentChannelAtom = Atom(
|
||||
name: '_SettingsStoreBase.focusCurrentChannel',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
bool get focusCurrentChannel {
|
||||
_$focusCurrentChannelAtom.reportRead();
|
||||
return super.focusCurrentChannel;
|
||||
}
|
||||
|
||||
@override
|
||||
set focusCurrentChannel(bool value) {
|
||||
_$focusCurrentChannelAtom.reportWrite(value, super.focusCurrentChannel, () {
|
||||
super.focusCurrentChannel = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$autoSyncChatDelayAtom = Atom(
|
||||
name: '_SettingsStoreBase.autoSyncChatDelay',
|
||||
context: context,
|
||||
|
|
@ -1226,6 +1271,8 @@ fontSize: ${fontSize},
|
|||
showDeletedMessages: ${showDeletedMessages},
|
||||
showChatMessageDividers: ${showChatMessageDividers},
|
||||
timestampType: ${timestampType},
|
||||
showHistoricalTimestamps: ${showHistoricalTimestamps},
|
||||
focusCurrentChannel: ${focusCurrentChannel},
|
||||
autoSyncChatDelay: ${autoSyncChatDelay},
|
||||
chatDelay: ${chatDelay},
|
||||
syncedChatDelay: ${syncedChatDelay},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -37,23 +37,23 @@ class FrostyColors extends ThemeExtension<FrostyColors> {
|
|||
required this.overlayOnSurface,
|
||||
});
|
||||
|
||||
static const light = FrostyColors(
|
||||
highlightedMessage: Color(0xff9146ff),
|
||||
emoteOnly: Color(0xFFFFB74D),
|
||||
followersOnly: Color(0xFFF44336),
|
||||
slowMode: Color(0xFF2196F3),
|
||||
subOnly: Color(0xFF4CAF50),
|
||||
uniqueMode: Color(0xFFAB47BC),
|
||||
static FrostyColors light({required ColorScheme colorScheme}) => FrostyColors(
|
||||
highlightedMessage: colorScheme.primary,
|
||||
emoteOnly: const Color(0xFFFFB74D),
|
||||
followersOnly: const Color(0xFFF44336),
|
||||
slowMode: const Color(0xFF2196F3),
|
||||
subOnly: const Color(0xFF4CAF50),
|
||||
uniqueMode: const Color(0xFFAB47BC),
|
||||
overlayOnSurface: Colors.white,
|
||||
);
|
||||
|
||||
static const dark = FrostyColors(
|
||||
highlightedMessage: Color(0xff9146ff),
|
||||
emoteOnly: Color(0xFFFFCC80),
|
||||
followersOnly: Color(0xFFE57373),
|
||||
slowMode: Color(0xFF64B5F6),
|
||||
subOnly: Color(0xFF81C784),
|
||||
uniqueMode: Color(0xFFBA68C8),
|
||||
static FrostyColors dark({required ColorScheme colorScheme}) => FrostyColors(
|
||||
highlightedMessage: colorScheme.primary,
|
||||
emoteOnly: const Color(0xFFFFCC80),
|
||||
followersOnly: const Color(0xFFE57373),
|
||||
slowMode: const Color(0xFF64B5F6),
|
||||
subOnly: const Color(0xFF81C784),
|
||||
uniqueMode: const Color(0xFFBA68C8),
|
||||
overlayOnSurface: Colors.white,
|
||||
);
|
||||
|
||||
|
|
@ -130,11 +130,16 @@ class FrostyThemes {
|
|||
return ThemeData(
|
||||
extensions: [
|
||||
colorScheme.brightness == Brightness.dark
|
||||
? FrostyColors.dark
|
||||
: FrostyColors.light,
|
||||
? FrostyColors.dark(colorScheme: colorScheme)
|
||||
: FrostyColors.light(colorScheme: colorScheme),
|
||||
],
|
||||
colorScheme: colorScheme,
|
||||
fontFamily: 'Inter',
|
||||
textSelectionTheme: TextSelectionThemeData(
|
||||
cursorColor: colorScheme.primary,
|
||||
selectionColor: colorScheme.primary.withValues(alpha: 0.3),
|
||||
selectionHandleColor: colorScheme.primary,
|
||||
),
|
||||
scaffoldBackgroundColor: backgroundColor,
|
||||
bottomSheetTheme: BottomSheetThemeData(
|
||||
showDragHandle: true,
|
||||
|
|
@ -144,7 +149,7 @@ class FrostyThemes {
|
|||
menuTheme: MenuThemeData(
|
||||
style: MenuStyle(
|
||||
padding: const WidgetStatePropertyAll(
|
||||
EdgeInsets.symmetric(vertical: 4),
|
||||
EdgeInsets.symmetric(vertical: 2),
|
||||
),
|
||||
shape: WidgetStatePropertyAll(
|
||||
RoundedRectangleBorder(
|
||||
|
|
@ -155,7 +160,12 @@ class FrostyThemes {
|
|||
),
|
||||
),
|
||||
menuButtonTheme: MenuButtonThemeData(
|
||||
style: MenuItemButton.styleFrom(visualDensity: VisualDensity.compact),
|
||||
style: MenuItemButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
minimumSize: const Size(0, 40),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
dialogTheme: DialogThemeData(
|
||||
backgroundColor: backgroundColor,
|
||||
|
|
@ -199,6 +209,23 @@ class FrostyThemes {
|
|||
),
|
||||
),
|
||||
),
|
||||
listTileTheme: ListTileThemeData(
|
||||
titleTextStyle: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0,
|
||||
height: 1.5,
|
||||
),
|
||||
subtitleTextStyle: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
fontSize: 13,
|
||||
height: 1.4,
|
||||
),
|
||||
leadingAndTrailingTextStyle: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
elevation: 0,
|
||||
backgroundColor: backgroundColor,
|
||||
|
|
@ -241,46 +268,34 @@ class FrostyThemes {
|
|||
headlineSmall: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.019,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
|
||||
// App bar title
|
||||
titleLarge: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.019,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
|
||||
// Section titles
|
||||
titleMedium: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.011,
|
||||
),
|
||||
titleMedium: TextStyle(fontWeight: FontWeight.w600, letterSpacing: 0),
|
||||
|
||||
// Tab bar title
|
||||
titleSmall: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.011,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
|
||||
// Labels
|
||||
labelLarge: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: -0.006,
|
||||
),
|
||||
labelLarge: TextStyle(fontWeight: FontWeight.w500, letterSpacing: 0),
|
||||
labelMedium: TextStyle(fontWeight: FontWeight.w500, letterSpacing: 0),
|
||||
labelSmall: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.005,
|
||||
),
|
||||
labelSmall: TextStyle(fontWeight: FontWeight.w500, letterSpacing: 0),
|
||||
|
||||
// Body text
|
||||
bodyLarge: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: -0.011,
|
||||
),
|
||||
bodyMedium: TextStyle(letterSpacing: -0.006),
|
||||
bodyLarge: TextStyle(fontWeight: FontWeight.w500, letterSpacing: 0),
|
||||
bodyMedium: TextStyle(letterSpacing: 0),
|
||||
bodySmall: TextStyle(letterSpacing: 0),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 =====
|
||||
|
||||
|
|
|
|||
122
lib/widgets/chat_input/emote_text_span_builder.dart
Normal file
122
lib/widgets/chat_input/emote_text_span_builder.dart
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import 'package:extended_text_field/extended_text_field.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:frosty/models/emotes.dart';
|
||||
import 'package:frosty/widgets/frosty_cached_network_image.dart';
|
||||
|
||||
/// A [SpecialTextSpanBuilder] that renders emote names as inline images.
|
||||
///
|
||||
/// This builder parses input text word-by-word and converts recognized emote
|
||||
/// names into [ExtendedWidgetSpan] widgets displaying the emote image.
|
||||
/// Unrecognized words remain as regular text.
|
||||
class EmoteTextSpanBuilder extends SpecialTextSpanBuilder {
|
||||
/// Map of emote names to their [Emote] objects (global + channel emotes).
|
||||
final Map<String, Emote> emoteToObject;
|
||||
|
||||
/// Map of user-owned emotes (subscriber, bits, unlocked).
|
||||
final Map<String, Emote> userEmoteToObject;
|
||||
|
||||
/// The height to render emotes at.
|
||||
final double emoteSize;
|
||||
|
||||
EmoteTextSpanBuilder({
|
||||
required this.emoteToObject,
|
||||
required this.userEmoteToObject,
|
||||
required this.emoteSize,
|
||||
});
|
||||
|
||||
@override
|
||||
TextSpan build(
|
||||
String data, {
|
||||
TextStyle? textStyle,
|
||||
SpecialTextGestureTapCallback? onTap,
|
||||
}) {
|
||||
if (data.isEmpty) {
|
||||
return TextSpan(text: '', style: textStyle);
|
||||
}
|
||||
|
||||
final spans = <InlineSpan>[];
|
||||
var currentIndex = 0;
|
||||
|
||||
// Split by spaces but preserve space positions
|
||||
final parts = _splitPreservingSpaces(data);
|
||||
|
||||
for (final part in parts) {
|
||||
if (part == ' ') {
|
||||
// Add space as regular text
|
||||
spans.add(TextSpan(text: ' ', style: textStyle));
|
||||
currentIndex += 1;
|
||||
} else if (part.isNotEmpty) {
|
||||
// Check if this word is an emote - render immediately on exact match
|
||||
final emote = emoteToObject[part] ?? userEmoteToObject[part];
|
||||
|
||||
if (emote != null) {
|
||||
spans.add(_createEmoteSpan(emote, part, currentIndex));
|
||||
} else {
|
||||
spans.add(TextSpan(text: part, style: textStyle));
|
||||
}
|
||||
currentIndex += part.length;
|
||||
}
|
||||
}
|
||||
|
||||
return TextSpan(children: spans, style: textStyle);
|
||||
}
|
||||
|
||||
/// Splits text into words and spaces, preserving the spaces as separate elements.
|
||||
List<String> _splitPreservingSpaces(String text) {
|
||||
final result = <String>[];
|
||||
final buffer = StringBuffer();
|
||||
|
||||
for (var i = 0; i < text.length; i++) {
|
||||
final char = text[i];
|
||||
if (char == ' ') {
|
||||
if (buffer.isNotEmpty) {
|
||||
result.add(buffer.toString());
|
||||
buffer.clear();
|
||||
}
|
||||
result.add(' ');
|
||||
} else {
|
||||
buffer.write(char);
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.isNotEmpty) {
|
||||
result.add(buffer.toString());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Creates an [ExtendedWidgetSpan] for the given emote.
|
||||
ExtendedWidgetSpan _createEmoteSpan(Emote emote, String text, int start) {
|
||||
// Calculate dimensions - use emote's native size if available,
|
||||
// otherwise use the configured emoteSize
|
||||
final height = emote.height?.toDouble() ?? emoteSize;
|
||||
final width = emote.width?.toDouble();
|
||||
|
||||
return ExtendedWidgetSpan(
|
||||
actualText: text,
|
||||
start: start,
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 1),
|
||||
child: FrostyCachedNetworkImage(
|
||||
imageUrl: emote.url,
|
||||
height: height,
|
||||
width: width,
|
||||
useFade: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
SpecialText? createSpecialText(
|
||||
String flag, {
|
||||
TextStyle? textStyle,
|
||||
SpecialTextGestureTapCallback? onTap,
|
||||
int? index,
|
||||
}) {
|
||||
// We handle all parsing in the build method, so this returns null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -138,57 +138,61 @@ class _LiveIndicatorState extends State<LiveIndicator>
|
|||
);
|
||||
}
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _pingController!,
|
||||
builder: (context, child) {
|
||||
final pingSize = widget.size * _pingScale!.value;
|
||||
return SizedBox(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// Ping ring rendered outside without affecting layout
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: OverflowBox(
|
||||
maxWidth: pingSize,
|
||||
maxHeight: pingSize,
|
||||
child: Center(
|
||||
child: Opacity(
|
||||
opacity: _pingOpacity!.value,
|
||||
child: Container(
|
||||
width: pingSize,
|
||||
height: pingSize,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: widget.color.withValues(
|
||||
alpha: _borderOpacity,
|
||||
return SizedBox(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// Ping ring in its own RepaintBoundary so its 60fps animation
|
||||
// doesn't dirty the overlay's compositing layer (expensive with
|
||||
// Impeller + PlatformView).
|
||||
Positioned.fill(
|
||||
child: RepaintBoundary(
|
||||
child: AnimatedBuilder(
|
||||
animation: _pingController!,
|
||||
builder: (context, child) {
|
||||
final pingSize = widget.size * _pingScale!.value;
|
||||
return IgnorePointer(
|
||||
child: OverflowBox(
|
||||
maxWidth: pingSize,
|
||||
maxHeight: pingSize,
|
||||
child: Center(
|
||||
child: Opacity(
|
||||
opacity: _pingOpacity!.value,
|
||||
child: Container(
|
||||
width: pingSize,
|
||||
height: pingSize,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: widget.color.withValues(
|
||||
alpha: _borderOpacity,
|
||||
),
|
||||
width: _borderWidth,
|
||||
),
|
||||
width: _borderWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// Static center dot
|
||||
Container(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
// Static center dot
|
||||
Container(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
46
lib/widgets/measure_size.dart
Normal file
46
lib/widgets/measure_size.dart
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Reports the rendered size of [child] via [onChange] whenever it changes.
|
||||
///
|
||||
/// Unlike `LayoutBuilder` (which exposes incoming constraints, not intrinsic
|
||||
/// size), this measures the child's actual laid-out size. The callback is
|
||||
/// deferred to a post-frame callback so listeners can safely mutate state
|
||||
/// (e.g. a store observable) without triggering a build-phase mutation.
|
||||
class MeasureSize extends SingleChildRenderObjectWidget {
|
||||
const MeasureSize({
|
||||
super.key,
|
||||
required this.onChange,
|
||||
required Widget super.child,
|
||||
});
|
||||
|
||||
final ValueChanged<Size> onChange;
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) =>
|
||||
_MeasureSizeRenderObject(onChange);
|
||||
|
||||
@override
|
||||
void updateRenderObject(BuildContext context, RenderObject renderObject) {
|
||||
(renderObject as _MeasureSizeRenderObject).onChange = onChange;
|
||||
}
|
||||
}
|
||||
|
||||
class _MeasureSizeRenderObject extends RenderProxyBox {
|
||||
_MeasureSizeRenderObject(this.onChange);
|
||||
|
||||
ValueChanged<Size> onChange;
|
||||
Size? _oldSize;
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
super.performLayout();
|
||||
|
||||
final newSize = child?.size ?? Size.zero;
|
||||
if (_oldSize == newSize) return;
|
||||
_oldSize = newSize;
|
||||
|
||||
// Defer out of the layout phase so the callback can mutate observed state.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => onChange(newSize));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ class _SettingsPageLayoutState extends State<SettingsPageLayout> {
|
|||
|
||||
// Responsive values based on orientation
|
||||
final listPadding = EdgeInsets.only(
|
||||
top: isPortrait ? 116 : 92,
|
||||
top: isPortrait ? 124 : 100,
|
||||
bottom: widget.hasBottomPadding ? context.safePaddingBottom + 8 : 0,
|
||||
);
|
||||
|
||||
|
|
|
|||
33
pubspec.lock
33
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"
|
||||
|
|
|
|||
20
pubspec.yaml
20
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
|
||||
|
||||
|
|
|
|||
111
test/screens/channel/video/chat_latency_sync_test.dart
Normal file
111
test/screens/channel/video/chat_latency_sync_test.dart
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frosty/screens/channel/video/chat_latency_sync.dart';
|
||||
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
|
||||
void main() {
|
||||
// Reference the real constant so assertions track config changes rather
|
||||
// than a hardcoded magic number.
|
||||
final tolerance =
|
||||
VideoTimingConstants.chatSyncDriftToleranceSeconds.toDouble();
|
||||
|
||||
late SettingsStore settingsStore;
|
||||
late ChatLatencySync sync;
|
||||
|
||||
setUp(() {
|
||||
settingsStore = SettingsStore.fromJson({});
|
||||
sync = ChatLatencySync(settingsStore: settingsStore);
|
||||
});
|
||||
|
||||
group('ChatLatencySync reset', () {
|
||||
test('zeroes syncedChatDelay when autoSyncChatDelay is enabled', () {
|
||||
settingsStore.autoSyncChatDelay = true;
|
||||
settingsStore.syncedChatDelay = 7.5;
|
||||
|
||||
sync.reset();
|
||||
|
||||
expect(settingsStore.syncedChatDelay, 0.0);
|
||||
});
|
||||
|
||||
test('is a no-op when autoSyncChatDelay is disabled', () {
|
||||
settingsStore.autoSyncChatDelay = false;
|
||||
settingsStore.syncedChatDelay = 7.5;
|
||||
|
||||
sync.reset();
|
||||
|
||||
expect(settingsStore.syncedChatDelay, 7.5);
|
||||
});
|
||||
});
|
||||
|
||||
group('ChatLatencySync report', () {
|
||||
test('does nothing when autoSyncChatDelay is disabled', () {
|
||||
settingsStore.autoSyncChatDelay = false;
|
||||
settingsStore.syncedChatDelay = 0.0;
|
||||
|
||||
sync.report(12.0);
|
||||
|
||||
expect(settingsStore.syncedChatDelay, 0.0);
|
||||
});
|
||||
|
||||
test('writes the first measurement when current delay is zero', () {
|
||||
settingsStore.autoSyncChatDelay = true;
|
||||
settingsStore.syncedChatDelay = 0.0;
|
||||
|
||||
// Even a tiny value writes because the current value is exactly 0.
|
||||
sync.report(0.5);
|
||||
|
||||
expect(settingsStore.syncedChatDelay, 0.5);
|
||||
});
|
||||
|
||||
test('writes when drift exceeds the tolerance', () {
|
||||
settingsStore.autoSyncChatDelay = true;
|
||||
settingsStore.syncedChatDelay = 5.0;
|
||||
|
||||
final report = 5.0 + tolerance + 1.0;
|
||||
sync.report(report);
|
||||
|
||||
expect(settingsStore.syncedChatDelay, report);
|
||||
});
|
||||
|
||||
test('does not write when drift is within the tolerance', () {
|
||||
settingsStore.autoSyncChatDelay = true;
|
||||
settingsStore.syncedChatDelay = 5.0;
|
||||
|
||||
// Strictly inside the tolerance band.
|
||||
sync.report(5.0 + (tolerance - 0.5));
|
||||
|
||||
expect(settingsStore.syncedChatDelay, 5.0);
|
||||
});
|
||||
|
||||
test('does not write when drift is exactly at the tolerance', () {
|
||||
settingsStore.autoSyncChatDelay = true;
|
||||
settingsStore.syncedChatDelay = 5.0;
|
||||
|
||||
// The code uses strictly-greater-than (`>`), so a drift equal to the
|
||||
// tolerance must NOT write.
|
||||
sync.report(5.0 + tolerance);
|
||||
|
||||
expect(settingsStore.syncedChatDelay, 5.0);
|
||||
});
|
||||
|
||||
test('writes when drift just exceeds the tolerance', () {
|
||||
settingsStore.autoSyncChatDelay = true;
|
||||
settingsStore.syncedChatDelay = 5.0;
|
||||
|
||||
final report = 5.0 + tolerance + 0.1;
|
||||
sync.report(report);
|
||||
|
||||
expect(settingsStore.syncedChatDelay, report);
|
||||
});
|
||||
|
||||
test('writes when a downward drift exceeds the tolerance', () {
|
||||
settingsStore.autoSyncChatDelay = true;
|
||||
settingsStore.syncedChatDelay = 10.0;
|
||||
|
||||
final report = 10.0 - tolerance - 1.0;
|
||||
sync.report(report);
|
||||
|
||||
expect(settingsStore.syncedChatDelay, report);
|
||||
});
|
||||
});
|
||||
}
|
||||
194
test/screens/channel/video/stream_info_poller_test.dart
Normal file
194
test/screens/channel/video/stream_info_poller_test.dart
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frosty/apis/twitch_api.dart';
|
||||
import 'package:frosty/models/channel.dart';
|
||||
import 'package:frosty/models/stream.dart';
|
||||
import 'package:frosty/screens/channel/video/stream_info_poller.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../../fixtures/api_responses.dart';
|
||||
|
||||
class MockTwitchApi extends Mock implements TwitchApi {}
|
||||
|
||||
void main() {
|
||||
const userLogin = 'testuser';
|
||||
const userId = '12345';
|
||||
|
||||
late MockTwitchApi api;
|
||||
|
||||
// Fresh fixtures per test so nothing leaks between cases.
|
||||
StreamTwitch buildStream() => StreamTwitch.fromJson(
|
||||
(twitchStreamResponse['data']! as List).first as Map<String, dynamic>,
|
||||
);
|
||||
|
||||
Channel buildChannel() => Channel.fromJson(
|
||||
(twitchChannelResponse['data']! as List).first as Map<String, dynamic>,
|
||||
);
|
||||
|
||||
// Fresh poller per test so _lastUpdate / _inFlight debounce state never
|
||||
// carries over between tests.
|
||||
StreamInfoPoller buildPoller() =>
|
||||
StreamInfoPoller(twitchApi: api, userLogin: userLogin, userId: userId);
|
||||
|
||||
setUp(() {
|
||||
api = MockTwitchApi();
|
||||
});
|
||||
|
||||
group('live', () {
|
||||
test('returns stream when getStream succeeds', () async {
|
||||
when(
|
||||
() => api.getStream(userLogin: any(named: 'userLogin')),
|
||||
).thenAnswer((_) async => buildStream());
|
||||
|
||||
final poller = buildPoller();
|
||||
final result = await poller.fetch();
|
||||
|
||||
expect(result, isNotNull);
|
||||
expect(result!.stream, isNotNull);
|
||||
expect(result.stream!.userName, 'TestUser');
|
||||
expect(result.offlineChannel, isNull);
|
||||
|
||||
verify(() => api.getStream(userLogin: userLogin)).called(1);
|
||||
verifyNever(() => api.getChannel(userId: any(named: 'userId')));
|
||||
});
|
||||
});
|
||||
|
||||
group('debounce', () {
|
||||
test(
|
||||
'second fetch within debounce window returns null, forceUpdate bypasses',
|
||||
() async {
|
||||
when(
|
||||
() => api.getStream(userLogin: any(named: 'userLogin')),
|
||||
).thenAnswer((_) async => buildStream());
|
||||
|
||||
final poller = buildPoller();
|
||||
|
||||
// First fetch resolves with a real result.
|
||||
final first = await poller.fetch();
|
||||
expect(first, isNotNull);
|
||||
expect(first!.stream, isNotNull);
|
||||
|
||||
// Immediate second fetch (no forceUpdate) is inside the 5s debounce
|
||||
// window -> debounced -> null.
|
||||
final second = await poller.fetch();
|
||||
expect(second, isNull);
|
||||
|
||||
// forceUpdate bypasses the debounce -> a fresh result.
|
||||
final forced = await poller.fetch(forceUpdate: true);
|
||||
expect(forced, isNotNull);
|
||||
expect(forced!.stream, isNotNull);
|
||||
|
||||
// Two real fetches (first + forced); the debounced one issued no call.
|
||||
verify(() => api.getStream(userLogin: userLogin)).called(2);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('in-flight dedup', () {
|
||||
test(
|
||||
'concurrent fetches await the same request and call getStream once',
|
||||
() async {
|
||||
final completer = Completer<StreamTwitch>();
|
||||
when(
|
||||
() => api.getStream(userLogin: any(named: 'userLogin')),
|
||||
).thenAnswer((_) => completer.future);
|
||||
|
||||
final poller = buildPoller();
|
||||
|
||||
// Fire both without awaiting so the second sees the in-flight future.
|
||||
final firstFuture = poller.fetch();
|
||||
final secondFuture = poller.fetch();
|
||||
|
||||
// Now let the underlying request complete.
|
||||
completer.complete(buildStream());
|
||||
|
||||
final first = await firstFuture;
|
||||
final second = await secondFuture;
|
||||
|
||||
expect(first, isNotNull);
|
||||
expect(second, isNotNull);
|
||||
// Same deduplicated result instance.
|
||||
expect(identical(first, second), isTrue);
|
||||
expect(first!.stream, isNotNull);
|
||||
|
||||
verify(() => api.getStream(userLogin: userLogin)).called(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('second-chance retry', () {
|
||||
test(
|
||||
'getStream fails once then succeeds on retry -> stream, no getChannel',
|
||||
() async {
|
||||
var calls = 0;
|
||||
when(() => api.getStream(userLogin: any(named: 'userLogin'))).thenAnswer(
|
||||
(_) async {
|
||||
calls++;
|
||||
if (calls == 1) throw Exception('transient drop');
|
||||
return buildStream();
|
||||
},
|
||||
);
|
||||
|
||||
final poller = buildPoller();
|
||||
final result = await poller.fetch();
|
||||
|
||||
expect(result, isNotNull);
|
||||
expect(result!.stream, isNotNull);
|
||||
expect(result.offlineChannel, isNull);
|
||||
|
||||
verify(() => api.getStream(userLogin: userLogin)).called(2);
|
||||
verifyNever(() => api.getChannel(userId: any(named: 'userId')));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('offline', () {
|
||||
test(
|
||||
'getStream fails twice, getChannel succeeds -> offlineChannel',
|
||||
() async {
|
||||
when(
|
||||
() => api.getStream(userLogin: any(named: 'userLogin')),
|
||||
).thenThrow(Exception('offline'));
|
||||
when(
|
||||
() => api.getChannel(userId: any(named: 'userId')),
|
||||
).thenAnswer((_) async => buildChannel());
|
||||
|
||||
final poller = buildPoller();
|
||||
final result = await poller.fetch();
|
||||
|
||||
expect(result, isNotNull);
|
||||
expect(result!.stream, isNull);
|
||||
expect(result.offlineChannel, isNotNull);
|
||||
expect(result.offlineChannel!.broadcasterName, 'TestUser');
|
||||
|
||||
verify(() => api.getStream(userLogin: userLogin)).called(2);
|
||||
verify(() => api.getChannel(userId: userId)).called(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('total failure', () {
|
||||
test(
|
||||
'getStream fails twice and getChannel fails -> empty result',
|
||||
() async {
|
||||
when(
|
||||
() => api.getStream(userLogin: any(named: 'userLogin')),
|
||||
).thenThrow(Exception('stream boom'));
|
||||
when(
|
||||
() => api.getChannel(userId: any(named: 'userId')),
|
||||
).thenThrow(Exception('channel boom'));
|
||||
|
||||
final poller = buildPoller();
|
||||
final result = await poller.fetch();
|
||||
|
||||
expect(result, isNotNull);
|
||||
expect(result!.stream, isNull);
|
||||
expect(result.offlineChannel, isNull);
|
||||
|
||||
verify(() => api.getStream(userLogin: userLogin)).called(2);
|
||||
verify(() => api.getChannel(userId: userId)).called(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
77
test/screens/channel/video/video_timing_constants_test.dart
Normal file
77
test/screens/channel/video/video_timing_constants_test.dart
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
|
||||
|
||||
/// Regression guard for timing relationships used by Glacier's WebView video
|
||||
/// recovery and chat-sync logic.
|
||||
void main() {
|
||||
group('VideoTimingConstants latency vs chat-sync tolerance', () {
|
||||
test('highLatencyThreshold is much greater than chatSyncDriftTolerance', () {
|
||||
expect(
|
||||
VideoTimingConstants.highLatencyThresholdSeconds,
|
||||
greaterThan(VideoTimingConstants.chatSyncDriftToleranceSeconds),
|
||||
reason:
|
||||
'High-latency intervention must only trigger far past the chat-sync '
|
||||
'drift tolerance, otherwise normal sync drift could trigger '
|
||||
'playback recovery.',
|
||||
);
|
||||
});
|
||||
|
||||
test('highLatencyThreshold sits comfortably above normal live latency', () {
|
||||
expect(
|
||||
VideoTimingConstants.highLatencyThresholdSeconds,
|
||||
greaterThanOrEqualTo(15),
|
||||
reason:
|
||||
'highLatencyThresholdSeconds must stay above normal live latency '
|
||||
'so healthy playback is not flagged for recovery.',
|
||||
);
|
||||
});
|
||||
|
||||
test('maxHighLatencyRecoveries is positive and bounded', () {
|
||||
expect(VideoTimingConstants.maxHighLatencyRecoveries, greaterThan(0));
|
||||
expect(
|
||||
VideoTimingConstants.maxHighLatencyRecoveries,
|
||||
lessThanOrEqualTo(5),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('VideoTimingConstants stream info', () {
|
||||
test('polling and debounce intervals are positive', () {
|
||||
expect(
|
||||
VideoTimingConstants.streamInfoDebounce,
|
||||
greaterThan(Duration.zero),
|
||||
);
|
||||
expect(
|
||||
VideoTimingConstants.webviewStreamInfoInterval,
|
||||
greaterThan(Duration.zero),
|
||||
);
|
||||
expect(
|
||||
VideoTimingConstants.offlineConfirmationDelay,
|
||||
greaterThan(Duration.zero),
|
||||
);
|
||||
});
|
||||
|
||||
test('offline confirmation is faster than periodic polling', () {
|
||||
expect(
|
||||
VideoTimingConstants.offlineConfirmationDelay,
|
||||
lessThan(VideoTimingConstants.webviewStreamInfoInterval),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('VideoTimingConstants overlay and cleanup', () {
|
||||
test('overlayQuickHide is not slower than overlayAutoHide', () {
|
||||
expect(
|
||||
VideoTimingConstants.overlayQuickHide,
|
||||
lessThanOrEqualTo(VideoTimingConstants.overlayAutoHide),
|
||||
);
|
||||
});
|
||||
|
||||
test('jsCleanupInterval is positive', () {
|
||||
expect(
|
||||
VideoTimingConstants.jsCleanupInterval,
|
||||
greaterThan(Duration.zero),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue