feat(video,chat): native player, ad-break awareness, chat send/delay reliability, and tab redesign (#567)
Squash-merge commit. Covers four areas: native video player, chat reliability, multi-tab, and hardening. ## Native video player - Direct HLS playback via `better_native_video_player` (forked from `plug-and-pay/flutter-native-video-player`, patched on `frosty-patches` branch) - Twitch GQL playback token fetching with platform cookie extraction (iOS Swift + Android Kotlin plugins) for authenticated streams - Quality selection (source, 720p, 480p, 360p, 160p, audio-only) persisted per channel - Picture-in-picture: iOS automatic inline PiP, Android auto-PiP on API 31+ guarded against older devices - Latency polling, chat latency sync, stall detection/recovery, startup auto-pause recovery, watchdog timers - **Twitch stitched-ad break detection** (HLS `EXT-X-DATERANGE`) on both platforms, emitting `adBreakChanged` so the host suspends latency/stall recovery and chat sync during ads (program-date-time is unreliable then). Matches `CLASS="twitch-stitched-ad"` / `ID="stitched-ad-"` **and** the `X-TV-TWITCH-AD-*` attribute prefix (the durable signal streamlink/yt-dlp use) - Shared `VideoPlayerInterface` so WebView and native paths are interchangeable via settings toggle - `PlaybackAccessToken` model, `TwitchGqlApi` client ## Chat delay & send reliability - **Optimistic send-finalize**: Twitch acks a sent message with a per-PRIVMSG USERSTATE (not an echo), which it can intermittently drop — more so under a long chat delay. A missing ack no longer shows a false "message may not have been sent" error; the send is finalized optimistically (local echo rendered), and only genuine rejection NOTICEs surface an error. Ack timeout scaled by the chat delay. - **Per-line chat-delay bypass**: control/state lines (USERSTATE/ROOMSTATE/GLOBALUSERSTATE), rejection NOTICEs, the reconnect notice, and untagged server lines bypass the delay so send-acks and PONGs aren't stalled; only displayable chat content is delayed, staying in sync with delayed video. - **Measured chat bottom-bar height**: the message list reflows to the composer's real measured height, fixing the input overlapping messages when a long message expands it (replaces a fixed-constant estimate). - IRC tag parsing handles valueless and escaped tags per the IRCv3 message-tags spec (fixes a recent-messages RangeError on certain tags). ## Chat / multi-tab - Redesigned tab chips: unread dot indicator, chips dim until WebSocket connects - Animated tab options popover replacing `MenuAnchor` (no animation hook in Flutter 3.44) - Simplified `ChatTabs` from `StatefulWidget` to `StatelessWidget` - Focus-current-channel fade bump to 0.55; section-header alpha lifted to match the muted-label tier - `addTab` now calls `setActiveTab` to snapshot outgoing tab's `lastSeenMessageId` — fixes spurious unread dots after adding a tab - `onActivateSourceTab` threaded into `ChatUserModal` so Reply in merged mode switches to the correct source tab's composer ## Bug fixes and hardening - **Reverted the experimental iOS low-latency mode** (5s live-edge join + 1.05x catch-up): AVPlayer can't sustain <~18s on Twitch (declared `TARGETDURATION:6` paces playlist refreshes), and the offset starved the buffer into pause-on-launch. Default join distance restored. - Fork (Android): **cap `BEHIND_LIVE_WINDOW` recovery** so a stream repeatedly falling off the live window can't silently re-prepare forever; emits `error` on exhaustion so the host recovery ladder takes over - Guard all Android `setAutoPipMode` calls with `_autoPipAvailable` — prevents `PlatformException` on Android 8–11; check `_disposed` after `isAutoPipAvailable` await to prevent post-dispose auto-PiP re-enable - Remove `_isInPipMode` guard from `_onPaused` so `_paused` tracks actual state, fixing PiP play/pause toggle getting stuck - Tighten iOS cookie domain check to exact `twitch.tv` / `.twitch.tv` suffix - Invalidate the stored web session token when Twitch rejects it with a 401 (profile card flips back to unlinked; future loads skip the doomed authenticated request), and re-extract the cookie on launch when logged in without a linked session - Send `play_session_id` and `player_backend=mediaplayer` to Usher, matching the web client / streamlink - Fork (iOS): restore program-date-time latency measurement (a prior rev measured distance to the manifest edge, reading ~0s and silently disabling high-latency recovery / chat-delay auto-sync); release platform views on dispose (per-stream view + AVPlayerViewController leak), track the observed AVPlayerItem for KVO removal (sibling-view crash), main-thread seek results, resolve superseded load futures - Fork (Android): clean up method handlers orphaned by PiP-time view disposal (leaked audio-focus listeners), drop the live-offset re-anchoring seekTo on metadata updates (latency creep), restore pre-duck volume, respect user pause in the stall watchdog, resolve superseded load futures - Deep review pass: expire the quality-switch flag so stall recovery isn't disabled for the session, cap latency-driven recovery (clock-skew loop), generation-guard the error overlay, two-strike offline confirmation, controller null-safety on quality switch - Tighten `/me` local-echo match (no longer renders `/meow ...` as an action); clear pending send text on disconnect ## Web / docs - Remove stale Cloudflare Images binding from `wrangler.jsonc` (covered by `images.unoptimized: true`) - Correct `web/AGENTS.md` Cache Components claim (feature not enabled) - Repo and agent config cleanup ## Test plan - [x] `flutter analyze` — clean (frosty + plugin lib) - [x] `flutter test` — 319/319 pass (adds `ChatLatencySync`, `StreamInfoPoller`, video timing invariants, `PlaybackAccessToken`, IRC tag parsing) - [x] Android plugin Kotlin compiles against the bumped fork rev - [ ] Native player plays live streams on iOS and Android - [ ] Quality switching works and persists across channel reopens - [ ] PiP enters/exits correctly; pause/resume toggle in PiP window works - [ ] Audio-only mode does not crash on Android < API 31 - [ ] Sending under a long synced chat delay no longer shows a false "may not have been sent" error - [ ] Long composer text reflows the message list (no input/message overlap) - [ ] Multi-tab: adding a tab does not mark previous tab as unread - [ ] Merged chat: Reply from user modal targets the correct tab's composer 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
parent
8883af90f0
commit
f2b932bc93
72 changed files with 7220 additions and 3816 deletions
|
|
@ -6,9 +6,13 @@ import 'package:flutter_mobx/flutter_mobx.dart';
|
|||
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
||||
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
|
||||
import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart';
|
||||
import 'package:frosty/screens/channel/video/native_video.dart';
|
||||
import 'package:frosty/screens/channel/video/native_video_player_interface.dart';
|
||||
import 'package:frosty/screens/channel/video/native_video_store.dart';
|
||||
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
|
||||
import 'package:frosty/screens/channel/video/video.dart';
|
||||
import 'package:frosty/screens/channel/video/video_overlay.dart';
|
||||
import 'package:frosty/screens/channel/video/video_player_interface.dart';
|
||||
import 'package:frosty/screens/channel/video/video_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
import 'package:frosty/theme.dart';
|
||||
|
|
@ -43,11 +47,10 @@ class _VideoChatState extends State<VideoChat>
|
|||
final _videoKey = GlobalKey();
|
||||
final _chatKey = GlobalKey();
|
||||
|
||||
// PiP drag state - essential only
|
||||
double _pipDragDistance = 0;
|
||||
// PiP drag state — ValueNotifier avoids full widget rebuilds during drag.
|
||||
final _pipDragDistance = ValueNotifier<double>(0);
|
||||
bool _isPipDragging = false;
|
||||
bool _isInPipTriggerZone =
|
||||
false; // Track when in trigger zone for haptic feedback
|
||||
bool _isInPipTriggerZone = false;
|
||||
|
||||
// Divider drag state for synchronizing animation
|
||||
bool _isDividerDragging = false;
|
||||
|
|
@ -75,13 +78,24 @@ class _VideoChatState extends State<VideoChat>
|
|||
primaryDisplayName: widget.userName,
|
||||
);
|
||||
|
||||
late final VideoStore _videoStore = VideoStore(
|
||||
userLogin: widget.userLogin,
|
||||
userId: widget.userId,
|
||||
twitchApi: context.twitchApi,
|
||||
authStore: context.authStore,
|
||||
settingsStore: context.settingsStore,
|
||||
);
|
||||
late final VideoPlayerInterface _videoStore =
|
||||
context.settingsStore.useNativePlayer
|
||||
? NativeVideoStore(
|
||||
userLogin: widget.userLogin,
|
||||
userId: widget.userId,
|
||||
displayName: widget.userName,
|
||||
twitchApi: context.twitchApi,
|
||||
twitchGqlApi: context.twitchGqlApi,
|
||||
authStore: context.authStore,
|
||||
settingsStore: context.settingsStore,
|
||||
)
|
||||
: VideoStore(
|
||||
userLogin: widget.userLogin,
|
||||
userId: widget.userId,
|
||||
twitchApi: context.twitchApi,
|
||||
authStore: context.authStore,
|
||||
settingsStore: context.settingsStore,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -98,12 +112,10 @@ class _VideoChatState extends State<VideoChat>
|
|||
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
|
||||
);
|
||||
|
||||
// Attach listener to the controller (not the derived animation) so it
|
||||
// survives _springBackAnimation being reassigned in _animateSpringBack.
|
||||
// Drive the ValueNotifier from the spring-back animation so the
|
||||
// AnimatedBuilder rebuilds without a full setState.
|
||||
_animationController.addListener(() {
|
||||
setState(() {
|
||||
_pipDragDistance = _springBackAnimation.value;
|
||||
});
|
||||
_pipDragDistance.value = _springBackAnimation.value;
|
||||
});
|
||||
|
||||
// Register as observer for app lifecycle events
|
||||
|
|
@ -120,58 +132,58 @@ 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
|
||||
_animationController.stop();
|
||||
_pipDragDistance.value = 0;
|
||||
_isInPipTriggerZone = false;
|
||||
setState(() {
|
||||
_isPipDragging = true;
|
||||
_pipDragDistance = 0;
|
||||
_isInPipTriggerZone = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _handlePipDragUpdate(DragUpdateDetails details) {
|
||||
if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) {
|
||||
if (!_isPipDragging ||
|
||||
_videoStore.isInPipMode ||
|
||||
_videoStore.paused ||
|
||||
_videoStore.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_pipDragDistance += details.delta.dy;
|
||||
_pipDragDistance = _pipDragDistance.clamp(0, _pipMaxDragDistance);
|
||||
// Update the ValueNotifier directly — only the ListenableBuilder rebuilds,
|
||||
// not the entire channel scaffold.
|
||||
_pipDragDistance.value =
|
||||
(_pipDragDistance.value + details.delta.dy).clamp(0, _pipMaxDragDistance);
|
||||
|
||||
// Check if we've entered or exited the trigger zone for haptic feedback
|
||||
final wasInTriggerZone = _isInPipTriggerZone;
|
||||
_isInPipTriggerZone = _pipDragDistance >= _pipTriggerDistance;
|
||||
final wasInTriggerZone = _isInPipTriggerZone;
|
||||
_isInPipTriggerZone = _pipDragDistance.value >= _pipTriggerDistance;
|
||||
|
||||
// Provide haptic feedback when entering the trigger zone
|
||||
if (!wasInTriggerZone && _isInPipTriggerZone) {
|
||||
HapticFeedback.mediumImpact(); // Entering trigger zone
|
||||
}
|
||||
// Provide subtle haptic feedback when exiting the trigger zone
|
||||
else if (wasInTriggerZone && !_isInPipTriggerZone) {
|
||||
HapticFeedback.lightImpact(); // Exiting trigger zone
|
||||
}
|
||||
});
|
||||
if (!wasInTriggerZone && _isInPipTriggerZone) {
|
||||
HapticFeedback.mediumImpact();
|
||||
} else if (wasInTriggerZone && !_isInPipTriggerZone) {
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
}
|
||||
|
||||
void _handlePipDragEnd(DragEndDetails details) {
|
||||
if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) {
|
||||
if (!_isPipDragging ||
|
||||
_videoStore.isInPipMode ||
|
||||
_videoStore.paused ||
|
||||
_videoStore.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
final velocity = details.velocity.pixelsPerSecond.dy;
|
||||
final shouldTriggerPip =
|
||||
_pipDragDistance >= _pipTriggerDistance ||
|
||||
velocity > 600; // Simple velocity threshold
|
||||
_pipDragDistance.value >= _pipTriggerDistance || velocity > 600;
|
||||
|
||||
if (shouldTriggerPip) {
|
||||
// Simple haptic feedback on success
|
||||
HapticFeedback.mediumImpact();
|
||||
_videoStore.requestPictureInPicture();
|
||||
_resetDragState();
|
||||
} else {
|
||||
// Animate back to original position
|
||||
_animateSpringBack();
|
||||
}
|
||||
}
|
||||
|
|
@ -182,16 +194,16 @@ class _VideoChatState extends State<VideoChat>
|
|||
}
|
||||
|
||||
void _resetDragState() {
|
||||
_pipDragDistance.value = 0;
|
||||
_isInPipTriggerZone = false;
|
||||
setState(() {
|
||||
_isPipDragging = false;
|
||||
_pipDragDistance = 0;
|
||||
_isInPipTriggerZone = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _animateSpringBack() {
|
||||
_springBackAnimation = Tween<double>(begin: _pipDragDistance, end: 0)
|
||||
.animate(
|
||||
_springBackAnimation =
|
||||
Tween<double>(begin: _pipDragDistance.value, end: 0).animate(
|
||||
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
|
||||
);
|
||||
|
||||
|
|
@ -229,13 +241,13 @@ class _VideoChatState extends State<VideoChat>
|
|||
? AspectRatio(aspectRatio: aspectRatio, child: child)
|
||||
: child;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
// Listen to the ValueNotifier (drag) and AnimationController (spring-back)
|
||||
// so only this subtree rebuilds during the gesture — not the full scaffold.
|
||||
return ListenableBuilder(
|
||||
listenable: _pipDragDistance,
|
||||
child: wrappedChild,
|
||||
builder: (context, animatedChild) {
|
||||
final currentDragDistance = _isPipDragging
|
||||
? _pipDragDistance
|
||||
: _springBackAnimation.value;
|
||||
final currentDragDistance = _pipDragDistance.value;
|
||||
|
||||
final scaleFactor =
|
||||
1.0 - (currentDragDistance / _pipMaxDragDistance * 0.1);
|
||||
|
|
@ -256,11 +268,13 @@ class _VideoChatState extends State<VideoChat>
|
|||
if (!_videoStore.isInPipMode)
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
ignoring: !(_isPipDragging && _pipDragDistance > 0),
|
||||
ignoring:
|
||||
!(_isPipDragging && currentDragDistance > 0),
|
||||
child: AnimatedOpacity(
|
||||
opacity: (_isPipDragging && _pipDragDistance > 0)
|
||||
? 1.0
|
||||
: 0.0,
|
||||
opacity:
|
||||
(_isPipDragging && currentDragDistance > 0)
|
||||
? 1.0
|
||||
: 0.0,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOut,
|
||||
child: Container(
|
||||
|
|
@ -297,7 +311,12 @@ class _VideoChatState extends State<VideoChat>
|
|||
|
||||
final player = GestureDetector(
|
||||
onLongPress: _videoStore.handleToggleOverlay,
|
||||
child: Video(key: _videoKey, videoStore: _videoStore),
|
||||
child: _videoStore is NativeVideoStore
|
||||
? NativeVideo(nativeVideoStore: _videoStore)
|
||||
: Video(
|
||||
key: _videoKey,
|
||||
videoStore: _videoStore as VideoStore,
|
||||
),
|
||||
);
|
||||
|
||||
final overlay = GestureDetector(
|
||||
|
|
@ -324,7 +343,9 @@ class _VideoChatState extends State<VideoChat>
|
|||
settingsStore: settingsStore,
|
||||
);
|
||||
|
||||
if (_videoStore.paused || _videoStore.streamInfo == null) {
|
||||
if (_videoStore.paused ||
|
||||
_videoStore.streamInfo == null ||
|
||||
_videoStore.loading) {
|
||||
return videoOverlay;
|
||||
}
|
||||
|
||||
|
|
@ -344,15 +365,49 @@ 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,
|
||||
// Appear instantly to cover native player placeholders during
|
||||
// recovery; fade out smoothly when playback resumes.
|
||||
duration: _videoStore.loading
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
// TickerMode stops the spinner's repeating animation while
|
||||
// hidden — opacity 0 skips painting but would otherwise keep
|
||||
// scheduling frames for the whole video session.
|
||||
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(
|
||||
|
|
@ -683,7 +738,7 @@ class _VideoChatState extends State<VideoChat>
|
|||
if (settingsStore.showVideo) ...[
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Container(), // Placeholder for video space
|
||||
child: SizedBox(),
|
||||
),
|
||||
],
|
||||
Expanded(child: chat),
|
||||
|
|
@ -710,9 +765,18 @@ class _VideoChatState extends State<VideoChat>
|
|||
|
||||
// If on Android, use PiPSwitcher to enable PiP functionality.
|
||||
if (Platform.isAndroid) {
|
||||
void notifyPip(bool isInPip) {
|
||||
final store = _videoStore;
|
||||
if (store is NativeVideoPlayerInterface) {
|
||||
store.handleAndroidPipChanged(isInPip);
|
||||
}
|
||||
}
|
||||
|
||||
return PipWidget(
|
||||
pipLayout: PipActionsLayout.mediaOnlyPause,
|
||||
onPipAction: (_) => _videoStore.handlePausePlay(),
|
||||
onPipEntered: () => notifyPip(true),
|
||||
onPipExited: () => notifyPip(false),
|
||||
pipChild: player,
|
||||
child: videoChat,
|
||||
);
|
||||
|
|
@ -731,6 +795,7 @@ class _VideoChatState extends State<VideoChat>
|
|||
_videoStore.dispose();
|
||||
|
||||
_animationController.dispose();
|
||||
_pipDragDistance.dispose();
|
||||
|
||||
SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.manual,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -145,6 +150,7 @@ class Chat extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
addAutomaticKeepAlives: false,
|
||||
scrollCacheExtent: _chatCacheExtent,
|
||||
controller: scrollController,
|
||||
itemCount: chatStore.renderMessages.length,
|
||||
itemBuilder: (context, index) => ChatMessage(
|
||||
|
|
@ -172,6 +178,7 @@ class Chat extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
addAutomaticKeepAlives: false,
|
||||
scrollCacheExtent: _chatCacheExtent,
|
||||
controller: scrollController,
|
||||
itemCount: mergedMessages.length,
|
||||
itemBuilder: (context, index) {
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ class ChatModes extends StatelessWidget {
|
|||
style: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
).colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -15,6 +15,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';
|
||||
|
|
@ -66,32 +67,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;
|
||||
|
||||
|
|
@ -131,6 +141,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;
|
||||
|
||||
|
|
@ -158,6 +174,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>[];
|
||||
|
||||
|
|
@ -300,25 +322,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({
|
||||
|
|
@ -337,6 +360,21 @@ 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(
|
||||
(_) => [
|
||||
|
|
@ -449,7 +487,7 @@ abstract class ChatStoreBase with Store {
|
|||
) {
|
||||
queue.add(
|
||||
_DelayedCallback(
|
||||
releaseAt: DateTime.now().add(Duration(seconds: _chatDelaySeconds)),
|
||||
releaseAt: DateTime.now().add(_chatDelay),
|
||||
callback: callback,
|
||||
),
|
||||
);
|
||||
|
|
@ -518,6 +556,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
|
||||
|
|
@ -549,19 +596,18 @@ 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) {
|
||||
|
|
@ -577,6 +623,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) {
|
||||
|
|
@ -645,23 +694,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:
|
||||
|
|
@ -825,8 +864,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) {
|
||||
|
|
@ -913,8 +953,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
|
||||
|
|
@ -927,13 +968,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) {
|
||||
|
|
@ -954,6 +1009,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;
|
||||
|
|
@ -1124,7 +1187,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(
|
||||
|
|
@ -1209,53 +1272,92 @@ 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;
|
||||
|
||||
|
|
@ -515,10 +519,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) {
|
||||
|
|
@ -639,6 +642,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
|
||||
|
|
@ -647,6 +674,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.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ 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;
|
||||
|
|
@ -393,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(
|
||||
|
|
@ -400,7 +408,7 @@ class ChatBottomBar extends StatelessWidget {
|
|||
? MediaQuery.of(context).padding.bottom
|
||||
: 0,
|
||||
),
|
||||
child: bottomBarContent,
|
||||
child: measuredBar,
|
||||
)
|
||||
: BlurredContainer(
|
||||
gradientDirection: GradientDirection.down,
|
||||
|
|
@ -409,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;
|
||||
|
|
|
|||
|
|
@ -38,7 +38,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,16 +55,10 @@ class ChatTabs extends StatelessWidget {
|
|||
final tabs = chatTabsStore.tabs;
|
||||
final activeIndex = chatTabsStore.activeTabIndex;
|
||||
final showTabBar = chatTabsStore.showTabBar;
|
||||
final showMerge =
|
||||
tabs.where((t) => t.isActivated).length >= 2;
|
||||
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);
|
||||
|
|
@ -75,27 +68,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(
|
||||
|
|
@ -107,7 +93,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();
|
||||
}
|
||||
|
|
@ -120,7 +105,6 @@ class ChatTabs extends StatelessWidget {
|
|||
}).toList(),
|
||||
),
|
||||
),
|
||||
// Tab bar (only visible when more than 1 tab)
|
||||
if (showTabBar)
|
||||
Positioned(
|
||||
top: topInset,
|
||||
|
|
@ -130,8 +114,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,
|
||||
|
|
@ -161,7 +145,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)),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -171,7 +155,9 @@ class ChatTabs extends StatelessWidget {
|
|||
right: 12,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Center(child: _buildMergeToggle(context)),
|
||||
child: Center(
|
||||
child: _buildMoreActionsMenu(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -184,22 +170,47 @@ 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();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -207,86 +218,208 @@ 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);
|
||||
},
|
||||
),
|
||||
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),
|
||||
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,
|
||||
),
|
||||
),
|
||||
selected: isActive,
|
||||
showCheckmark: false,
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
onPressed: () {
|
||||
if (!isActive) {
|
||||
HapticFeedback.selectionClick();
|
||||
chatTabsStore.setActiveTab(index);
|
||||
}
|
||||
},
|
||||
onDeleted: () => controller.open(),
|
||||
deleteButtonTooltipMessage: 'Tab options',
|
||||
),
|
||||
),
|
||||
label: Text(
|
||||
displayName,
|
||||
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) return;
|
||||
HapticFeedback.selectionClick();
|
||||
chatTabsStore.setActiveTab(index);
|
||||
},
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
106
lib/screens/channel/video/native_video.dart
Normal file
106
lib/screens/channel/video/native_video.dart
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import 'package:better_native_video_player/better_native_video_player.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:frosty/screens/channel/video/native_video_store.dart';
|
||||
|
||||
/// Native video player widget backed by AVPlayerViewController (iOS)
|
||||
/// and ExoPlayer (Android).
|
||||
class NativeVideo extends StatelessWidget {
|
||||
final NativeVideoStore nativeVideoStore;
|
||||
|
||||
const NativeVideo({super.key, required this.nativeVideoStore});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Observer(
|
||||
builder: (context) {
|
||||
final controller = nativeVideoStore.controller;
|
||||
final error = nativeVideoStore.error;
|
||||
|
||||
final isOffline =
|
||||
!nativeVideoStore.loading &&
|
||||
nativeVideoStore.streamInfo == null &&
|
||||
error == null;
|
||||
|
||||
return ColoredBox(
|
||||
color: Colors.black,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (controller != null && !isOffline)
|
||||
NativeVideoPlayer(
|
||||
key: ObjectKey(controller),
|
||||
controller: controller,
|
||||
),
|
||||
if (controller != null && !nativeVideoStore.hasPlayedOnce && !isOffline)
|
||||
const ColoredBox(
|
||||
color: Colors.black,
|
||||
child: SizedBox.expand(),
|
||||
),
|
||||
if (error != null)
|
||||
Align(
|
||||
alignment: const Alignment(0, 0.3),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline_rounded,
|
||||
color: Colors.amber.withValues(alpha: 0.7),
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
error,
|
||||
style: const TextStyle(
|
||||
color: Colors.white60,
|
||||
fontSize: 13,
|
||||
height: 1.4,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isOffline)
|
||||
const Center(
|
||||
child: Icon(
|
||||
Icons.tv_off_rounded,
|
||||
color: Colors.white24,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
if (nativeVideoStore.isAudioOnlyMode && !isOffline)
|
||||
const ColoredBox(
|
||||
color: Colors.black,
|
||||
child: SizedBox.expand(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.headphones_rounded,
|
||||
color: Colors.white24,
|
||||
size: 32,
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Audio Only',
|
||||
style: TextStyle(
|
||||
color: Colors.white24,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
12
lib/screens/channel/video/native_video_player_interface.dart
Normal file
12
lib/screens/channel/video/native_video_player_interface.dart
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import 'package:frosty/screens/channel/video/video_player_interface.dart';
|
||||
|
||||
/// Capabilities that only the native player can meaningfully support.
|
||||
/// Callers that need these must type-check against this interface.
|
||||
abstract class NativeVideoPlayerInterface extends VideoPlayerInterface {
|
||||
/// Whether the current quality is an HLS audio-only variant.
|
||||
bool get isAudioOnlyMode;
|
||||
|
||||
/// Called from platform PiP callbacks on Android. Lets the store sync
|
||||
/// its overlay state with the OS PiP window.
|
||||
void handleAndroidPipChanged(bool isInPip);
|
||||
}
|
||||
1308
lib/screens/channel/video/native_video_store.dart
Normal file
1308
lib/screens/channel/video/native_video_store.dart
Normal file
File diff suppressed because it is too large
Load diff
497
lib/screens/channel/video/native_video_store.g.dart
Normal file
497
lib/screens/channel/video/native_video_store.g.dart
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'native_video_store.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// StoreGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
|
||||
|
||||
mixin _$NativeVideoStore on NativeVideoStoreBase, Store {
|
||||
Computed<String>? _$streamQualityComputed;
|
||||
|
||||
@override
|
||||
String get streamQuality => (_$streamQualityComputed ??= Computed<String>(
|
||||
() => super.streamQuality,
|
||||
name: 'NativeVideoStoreBase.streamQuality',
|
||||
)).value;
|
||||
|
||||
late final _$_controllerAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._controller',
|
||||
context: context,
|
||||
);
|
||||
|
||||
NativeVideoPlayerController? get controller {
|
||||
_$_controllerAtom.reportRead();
|
||||
return super._controller;
|
||||
}
|
||||
|
||||
@override
|
||||
NativeVideoPlayerController? get _controller => controller;
|
||||
|
||||
@override
|
||||
set _controller(NativeVideoPlayerController? value) {
|
||||
_$_controllerAtom.reportWrite(value, super._controller, () {
|
||||
super._controller = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_loadingAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._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: 'NativeVideoStoreBase._paused',
|
||||
context: context,
|
||||
);
|
||||
|
||||
bool get paused {
|
||||
_$_pausedAtom.reportRead();
|
||||
return super._paused;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get _paused => paused;
|
||||
|
||||
@override
|
||||
set _paused(bool value) {
|
||||
_$_pausedAtom.reportWrite(value, super._paused, () {
|
||||
super._paused = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_hasPlayedOnceAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._hasPlayedOnce',
|
||||
context: context,
|
||||
);
|
||||
|
||||
bool get hasPlayedOnce {
|
||||
_$_hasPlayedOnceAtom.reportRead();
|
||||
return super._hasPlayedOnce;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get _hasPlayedOnce => hasPlayedOnce;
|
||||
|
||||
@override
|
||||
set _hasPlayedOnce(bool value) {
|
||||
_$_hasPlayedOnceAtom.reportWrite(value, super._hasPlayedOnce, () {
|
||||
super._hasPlayedOnce = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_overlayVisibleAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._overlayVisible',
|
||||
context: context,
|
||||
);
|
||||
|
||||
bool get overlayVisible {
|
||||
_$_overlayVisibleAtom.reportRead();
|
||||
return super._overlayVisible;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get _overlayVisible => overlayVisible;
|
||||
|
||||
@override
|
||||
set _overlayVisible(bool value) {
|
||||
_$_overlayVisibleAtom.reportWrite(value, super._overlayVisible, () {
|
||||
super._overlayVisible = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_streamInfoAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._streamInfo',
|
||||
context: context,
|
||||
);
|
||||
|
||||
StreamTwitch? get streamInfo {
|
||||
_$_streamInfoAtom.reportRead();
|
||||
return super._streamInfo;
|
||||
}
|
||||
|
||||
@override
|
||||
StreamTwitch? get _streamInfo => streamInfo;
|
||||
|
||||
@override
|
||||
set _streamInfo(StreamTwitch? value) {
|
||||
_$_streamInfoAtom.reportWrite(value, super._streamInfo, () {
|
||||
super._streamInfo = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_offlineChannelInfoAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._offlineChannelInfo',
|
||||
context: context,
|
||||
);
|
||||
|
||||
Channel? get offlineChannelInfo {
|
||||
_$_offlineChannelInfoAtom.reportRead();
|
||||
return super._offlineChannelInfo;
|
||||
}
|
||||
|
||||
@override
|
||||
Channel? get _offlineChannelInfo => offlineChannelInfo;
|
||||
|
||||
@override
|
||||
set _offlineChannelInfo(Channel? value) {
|
||||
_$_offlineChannelInfoAtom.reportWrite(value, super._offlineChannelInfo, () {
|
||||
super._offlineChannelInfo = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_availableStreamQualitiesAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._availableStreamQualities',
|
||||
context: context,
|
||||
);
|
||||
|
||||
List<String> get availableStreamQualities {
|
||||
_$_availableStreamQualitiesAtom.reportRead();
|
||||
return super._availableStreamQualities;
|
||||
}
|
||||
|
||||
@override
|
||||
List<String> get _availableStreamQualities => availableStreamQualities;
|
||||
|
||||
@override
|
||||
set _availableStreamQualities(List<String> value) {
|
||||
_$_availableStreamQualitiesAtom.reportWrite(
|
||||
value,
|
||||
super._availableStreamQualities,
|
||||
() {
|
||||
super._availableStreamQualities = value;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
late final _$_restrictedStreamQualitiesAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._restrictedStreamQualities',
|
||||
context: context,
|
||||
);
|
||||
|
||||
List<String> get restrictedStreamQualities {
|
||||
_$_restrictedStreamQualitiesAtom.reportRead();
|
||||
return super._restrictedStreamQualities;
|
||||
}
|
||||
|
||||
@override
|
||||
List<String> get _restrictedStreamQualities => restrictedStreamQualities;
|
||||
|
||||
@override
|
||||
set _restrictedStreamQualities(List<String> value) {
|
||||
_$_restrictedStreamQualitiesAtom.reportWrite(
|
||||
value,
|
||||
super._restrictedStreamQualities,
|
||||
() {
|
||||
super._restrictedStreamQualities = value;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
late final _$_streamQualityIndexAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._streamQualityIndex',
|
||||
context: context,
|
||||
);
|
||||
|
||||
int get streamQualityIndex {
|
||||
_$_streamQualityIndexAtom.reportRead();
|
||||
return super._streamQualityIndex;
|
||||
}
|
||||
|
||||
@override
|
||||
int get _streamQualityIndex => streamQualityIndex;
|
||||
|
||||
@override
|
||||
set _streamQualityIndex(int value) {
|
||||
_$_streamQualityIndexAtom.reportWrite(value, super._streamQualityIndex, () {
|
||||
super._streamQualityIndex = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_latencyAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._latency',
|
||||
context: context,
|
||||
);
|
||||
|
||||
String? get latency {
|
||||
_$_latencyAtom.reportRead();
|
||||
return super._latency;
|
||||
}
|
||||
|
||||
@override
|
||||
String? get _latency => latency;
|
||||
|
||||
@override
|
||||
set _latency(String? value) {
|
||||
_$_latencyAtom.reportWrite(value, super._latency, () {
|
||||
super._latency = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_isInPipModeAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._isInPipMode',
|
||||
context: context,
|
||||
);
|
||||
|
||||
bool get isInPipMode {
|
||||
_$_isInPipModeAtom.reportRead();
|
||||
return super._isInPipMode;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get _isInPipMode => isInPipMode;
|
||||
|
||||
@override
|
||||
set _isInPipMode(bool value) {
|
||||
_$_isInPipModeAtom.reportWrite(value, super._isInPipMode, () {
|
||||
super._isInPipMode = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_isAudioOnlyModeAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._isAudioOnlyMode',
|
||||
context: context,
|
||||
);
|
||||
|
||||
bool get isAudioOnlyMode {
|
||||
_$_isAudioOnlyModeAtom.reportRead();
|
||||
return super._isAudioOnlyMode;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get _isAudioOnlyMode => isAudioOnlyMode;
|
||||
|
||||
@override
|
||||
set _isAudioOnlyMode(bool value) {
|
||||
_$_isAudioOnlyModeAtom.reportWrite(value, super._isAudioOnlyMode, () {
|
||||
super._isAudioOnlyMode = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_errorAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._error',
|
||||
context: context,
|
||||
);
|
||||
|
||||
String? get error {
|
||||
_$_errorAtom.reportRead();
|
||||
return super._error;
|
||||
}
|
||||
|
||||
@override
|
||||
String? get _error => error;
|
||||
|
||||
@override
|
||||
set _error(String? value) {
|
||||
_$_errorAtom.reportWrite(value, super._error, () {
|
||||
super._error = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_initPlayerAsyncAction = AsyncAction(
|
||||
'NativeVideoStoreBase._initPlayer',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> _initPlayer() {
|
||||
return _$_initPlayerAsyncAction.run(() => super._initPlayer());
|
||||
}
|
||||
|
||||
late final _$updateStreamQualitiesAsyncAction = AsyncAction(
|
||||
'NativeVideoStoreBase.updateStreamQualities',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> updateStreamQualities() {
|
||||
return _$updateStreamQualitiesAsyncAction.run(
|
||||
() => super.updateStreamQualities(),
|
||||
);
|
||||
}
|
||||
|
||||
late final _$setStreamQualityAsyncAction = AsyncAction(
|
||||
'NativeVideoStoreBase.setStreamQuality',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> setStreamQuality(String quality) {
|
||||
return _$setStreamQualityAsyncAction.run(
|
||||
() => super.setStreamQuality(quality),
|
||||
);
|
||||
}
|
||||
|
||||
late final _$_setStreamQualityIndexAsyncAction = AsyncAction(
|
||||
'NativeVideoStoreBase._setStreamQualityIndex',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> _setStreamQualityIndex(int index) {
|
||||
return _$_setStreamQualityIndexAsyncAction.run(
|
||||
() => super._setStreamQualityIndex(index),
|
||||
);
|
||||
}
|
||||
|
||||
late final _$updateStreamInfoAsyncAction = AsyncAction(
|
||||
'NativeVideoStoreBase.updateStreamInfo',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> updateStreamInfo({bool forceUpdate = false}) {
|
||||
return _$updateStreamInfoAsyncAction.run(
|
||||
() => super.updateStreamInfo(forceUpdate: forceUpdate),
|
||||
);
|
||||
}
|
||||
|
||||
late final _$NativeVideoStoreBaseActionController = ActionController(
|
||||
name: 'NativeVideoStoreBase',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> _reloadCurrentController() {
|
||||
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
|
||||
name: 'NativeVideoStoreBase._reloadCurrentController',
|
||||
);
|
||||
try {
|
||||
return super._reloadCurrentController();
|
||||
} finally {
|
||||
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void handleVideoTap() {
|
||||
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
|
||||
name: 'NativeVideoStoreBase.handleVideoTap',
|
||||
);
|
||||
try {
|
||||
return super.handleVideoTap();
|
||||
} finally {
|
||||
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void handleToggleOverlay() {
|
||||
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
|
||||
name: 'NativeVideoStoreBase.handleToggleOverlay',
|
||||
);
|
||||
try {
|
||||
return super.handleToggleOverlay();
|
||||
} finally {
|
||||
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> handleRefresh() {
|
||||
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
|
||||
name: 'NativeVideoStoreBase.handleRefresh',
|
||||
);
|
||||
try {
|
||||
return super.handleRefresh();
|
||||
} finally {
|
||||
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void togglePictureInPicture() {
|
||||
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
|
||||
name: 'NativeVideoStoreBase.togglePictureInPicture',
|
||||
);
|
||||
try {
|
||||
return super.togglePictureInPicture();
|
||||
} finally {
|
||||
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void handleAppResume() {
|
||||
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
|
||||
name: 'NativeVideoStoreBase.handleAppResume',
|
||||
);
|
||||
try {
|
||||
return super.handleAppResume();
|
||||
} finally {
|
||||
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void handleAndroidPipChanged(bool isInPip) {
|
||||
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
|
||||
name: 'NativeVideoStoreBase.handleAndroidPipChanged',
|
||||
);
|
||||
try {
|
||||
return super.handleAndroidPipChanged(isInPip);
|
||||
} finally {
|
||||
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
|
||||
name: 'NativeVideoStoreBase.dispose',
|
||||
);
|
||||
try {
|
||||
return super.dispose();
|
||||
} finally {
|
||||
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void _resetPlayerStateForChatOnly() {
|
||||
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
|
||||
name: 'NativeVideoStoreBase._resetPlayerStateForChatOnly',
|
||||
);
|
||||
try {
|
||||
return super._resetPlayerStateForChatOnly();
|
||||
} finally {
|
||||
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void _initFreshController() {
|
||||
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
|
||||
name: 'NativeVideoStoreBase._initFreshController',
|
||||
);
|
||||
try {
|
||||
return super._initFreshController();
|
||||
} finally {
|
||||
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''
|
||||
streamQuality: ${streamQuality}
|
||||
''';
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,10 +3,12 @@ import 'dart:io';
|
|||
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/native_video_store.dart';
|
||||
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
|
||||
import 'package:frosty/screens/channel/video/video_store.dart';
|
||||
import 'package:frosty/screens/channel/video/video_player_interface.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
import 'package:frosty/theme.dart';
|
||||
import 'package:frosty/utils.dart';
|
||||
|
|
@ -21,7 +23,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||
|
||||
/// Creates a widget containing controls which enable interactions with an underlying [Video] widget.
|
||||
class VideoOverlay extends StatelessWidget {
|
||||
final VideoStore videoStore;
|
||||
final VideoPlayerInterface videoStore;
|
||||
final ChatStore chatStore;
|
||||
final SettingsStore settingsStore;
|
||||
|
||||
|
|
@ -88,7 +90,7 @@ class VideoOverlay extends StatelessWidget {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SectionHeader(
|
||||
'Stream quality',
|
||||
'Quality',
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
isFirst: true,
|
||||
),
|
||||
|
|
@ -97,26 +99,35 @@ class VideoOverlay extends StatelessWidget {
|
|||
builder: (context) => ListView(
|
||||
shrinkWrap: true,
|
||||
primary: false,
|
||||
children: videoStore.availableStreamQualities
|
||||
.map(
|
||||
(quality) => ListTile(
|
||||
trailing: videoStore.streamQuality == quality
|
||||
? const Icon(Icons.check_rounded)
|
||||
: null,
|
||||
title: Text(quality),
|
||||
onTap: () {
|
||||
videoStore.setStreamQuality(quality);
|
||||
SharedPreferences.getInstance().then(
|
||||
(prefs) => prefs.setString(
|
||||
'last_stream_quality',
|
||||
quality,
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
children: [
|
||||
...videoStore.availableStreamQualities.map(
|
||||
(quality) => ListTile(
|
||||
trailing: videoStore.streamQuality == quality
|
||||
? const Icon(Icons.check_rounded)
|
||||
: null,
|
||||
title: Text(quality),
|
||||
onTap: () {
|
||||
videoStore.setStreamQuality(quality);
|
||||
SharedPreferences.getInstance().then(
|
||||
(prefs) => prefs.setString(
|
||||
lastStreamQualityKey(videoStore.userLogin),
|
||||
quality,
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
// Qualities the broadcaster offers only to subscribers
|
||||
// (from the playback token's restricted_bitrates).
|
||||
...videoStore.restrictedStreamQualities.map(
|
||||
(quality) => ListTile(
|
||||
enabled: false,
|
||||
title: Text(quality),
|
||||
subtitle: const Text('Subscribers only'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -259,7 +270,6 @@ class VideoOverlay extends StatelessWidget {
|
|||
displayName: chatStore.displayName,
|
||||
showUptime: false,
|
||||
showViewerCount: false,
|
||||
showOfflineIndicator: false,
|
||||
textColor: surfaceColor,
|
||||
isOffline: true,
|
||||
isInSharedChatMode:
|
||||
|
|
@ -344,32 +354,36 @@ class VideoOverlay extends StatelessWidget {
|
|||
if (videoStore.settingsStore.fullScreen &&
|
||||
context.isLandscape)
|
||||
chatOverlayButton,
|
||||
if (!Platform.isIOS || isIPad()) videoSettingsButton,
|
||||
if (!Platform.isIOS ||
|
||||
isIPad() ||
|
||||
videoStore is NativeVideoStore)
|
||||
videoSettingsButton,
|
||||
],
|
||||
),
|
||||
Center(
|
||||
child: 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),
|
||||
),
|
||||
],
|
||||
if (!videoStore.loading)
|
||||
Center(
|
||||
child: 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(
|
||||
|
|
|
|||
43
lib/screens/channel/video/video_player_interface.dart
Normal file
43
lib/screens/channel/video/video_player_interface.dart
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import 'package:frosty/models/channel.dart';
|
||||
import 'package:frosty/models/stream.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
|
||||
/// Abstraction over the video player store so both [VideoStore] (WebView)
|
||||
/// and [NativeVideoStore] can drive the same overlay and channel UI.
|
||||
///
|
||||
/// Native-only capabilities live on [NativeVideoPlayerInterface].
|
||||
abstract class VideoPlayerInterface {
|
||||
SettingsStore get settingsStore;
|
||||
|
||||
/// Channel this store is driving — used as a key for per-channel
|
||||
/// persistence (e.g. last-selected quality).
|
||||
String get userLogin;
|
||||
|
||||
bool get loading;
|
||||
bool get paused;
|
||||
bool get overlayVisible;
|
||||
bool get isInPipMode;
|
||||
StreamTwitch? get streamInfo;
|
||||
Channel? get offlineChannelInfo;
|
||||
List<String> get availableStreamQualities;
|
||||
|
||||
/// Qualities that exist for this stream but require a subscription.
|
||||
/// Only the native player can know this (parsed from the playback token);
|
||||
/// the WebView player always reports none.
|
||||
List<String> get restrictedStreamQualities;
|
||||
|
||||
String get streamQuality;
|
||||
String? get latency;
|
||||
|
||||
void handleVideoTap();
|
||||
void handlePausePlay();
|
||||
void handleToggleOverlay();
|
||||
Future<void> handleRefresh();
|
||||
void requestPictureInPicture();
|
||||
void togglePictureInPicture();
|
||||
Future<void> updateStreamQualities();
|
||||
Future<void> setStreamQuality(String quality);
|
||||
Future<void> updateStreamInfo({bool forceUpdate});
|
||||
void handleAppResume();
|
||||
void dispose();
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
|
|
|
|||
67
lib/screens/channel/video/video_timing_constants.dart
Normal file
67
lib/screens/channel/video/video_timing_constants.dart
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/// 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._();
|
||||
|
||||
// Stall recovery
|
||||
//
|
||||
// Cross-layer ordering contract: iOS's native notification-stall recovery
|
||||
// fires at ~1s, this Dart timer at 8s, and the Android native watchdog
|
||||
// (VideoPlayerObserver.kt STALL_WATCHDOG_MS) at 12s. Correctness depends
|
||||
// on 1s < stallDetectionDelay < 12s — Dart's attempt-1 seek must pre-empt
|
||||
// the Android watchdog's heavier stop()/prepare() recovery, and iOS has
|
||||
// no native watchdog so the Dart timer is the only recoverer for silent
|
||||
// rate-drops there. Don't tune past either neighbor.
|
||||
static const stallDetectionDelay = Duration(seconds: 8);
|
||||
static const initRetryDelay = Duration(seconds: 3);
|
||||
static const int maxRefreshAttempts = 3;
|
||||
|
||||
/// Delay between stall-recovery full refreshes, indexed by attempt number
|
||||
/// (0 = first attempt). Attempts beyond the list length fall through to
|
||||
/// the error path via [maxRefreshAttempts].
|
||||
static const refreshBackoff = <Duration>[
|
||||
Duration.zero,
|
||||
Duration(seconds: 3),
|
||||
Duration(seconds: 8),
|
||||
];
|
||||
|
||||
/// A "play" event arriving within this window after a previous one is
|
||||
/// treated as a failed-recovery bounce rather than a fresh successful start.
|
||||
static const shortPlayWindow = Duration(seconds: 10);
|
||||
/// Number of short-play bounces required before surfacing a tailored error.
|
||||
static const int shortPlayLoopThreshold = 3;
|
||||
|
||||
// Latency / chat sync
|
||||
static const latencyPollingInterval = Duration(seconds: 3);
|
||||
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;
|
||||
|
||||
/// How long a quality switch may suppress stall classification before the
|
||||
/// flag expires. A resolution-only switch never interrupts playback, so no
|
||||
/// play/pause event arrives to clear it — without this backstop the flag
|
||||
/// sticks for the whole session and disables stall recovery.
|
||||
static const qualitySwitchGrace = Duration(seconds: 5);
|
||||
|
||||
/// 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);
|
||||
static const nativeStreamInfoInterval = Duration(seconds: 30);
|
||||
|
||||
// WebView housekeeping
|
||||
static const jsCleanupInterval = Duration(minutes: 10);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue