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:
Tommy Chow 2026-06-13 17:06:24 -04:00 • committed by GitHub
parent 8883af90f0
commit f2b932bc93
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 7220 additions and 3816 deletions

View file

@ -0,0 +1,132 @@
import 'dart:math';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/models/playback_access_token.dart';
/// Twitch GQL API client for fetching playback access tokens.
///
/// Uses the Twitch web client ID (not the app's registered client ID)
/// since the GQL endpoint requires it.
class TwitchGqlApi extends BaseApiClient {
TwitchGqlApi(Dio dio) : super(dio, 'https://gql.twitch.tv');
/// Browser-consistent User-Agent for playback-related requests. Twitch has
/// rejected access-token requests with non-browser UAs before
/// (streamlink#6574); sending one consistent with the web client ID and
/// web session token avoids the obvious fingerprint mismatch.
static const playbackUserAgent =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
/// Headers for the HLS playlist/segment requests issued by the native
/// player, mirroring what the web player sends to Usher/weaver.
static const playbackHttpHeaders = <String, String>{
'User-Agent': playbackUserAgent,
'Referer': 'https://player.twitch.tv',
'Origin': 'https://player.twitch.tv',
};
/// Fetches a playback access token for the given channel [login].
///
/// When [authToken] is provided, the request is authenticated which may
/// grant ad-free playback for subscribers and Turbo users.
///
/// Uses the web client's persisted GQL query (smaller payload, blends in
/// with regular web traffic); falls back to the inline query template if
/// Twitch ever rotates the persisted-query hash.
Future<PlaybackAccessToken> getPlaybackAccessToken({
required String login,
String? authToken,
}) async {
final headers = <String, String>{
'Client-ID': twitchGqlClientId,
'User-Agent': playbackUserAgent,
if (authToken != null) 'Authorization': 'OAuth $authToken',
};
final variables = {
'isLive': true,
'login': login,
'isVod': false,
'vodID': '',
'playerType': 'site',
'platform': 'web',
};
Future<JsonMap> query(Map<String, Object> body) =>
post<JsonMap>('/gql', data: body, headers: headers);
final response = await query({
'operationName': 'PlaybackAccessToken',
'extensions': {
'persistedQuery': {
'version': 1,
// Stable for years; used by the web player and streamlink.
'sha256Hash':
'ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9',
},
},
'variables': variables,
});
// PersistedQueryNotFound (or similar) comes back as a 200 with an
// errors array — retry once with the inline template before failing.
if (response['errors'] != null && response['data'] == null) {
debugPrint(
'TwitchGqlApi: persisted query failed (${response['errors']}), '
'falling back to inline template',
);
return PlaybackAccessToken.fromGqlResponse(
await query({
'operationName': 'PlaybackAccessToken_Template',
'query':
'query PlaybackAccessToken_Template(\$login: String!, \$isLive: Boolean!, \$vodID: ID!, \$isVod: Boolean!, \$playerType: String!) { streamPlaybackAccessToken(channelName: \$login, params: {platform: "web", playerBackend: "mediaplayer", playerType: \$playerType}) @include(if: \$isLive) { value signature __typename } videoPlaybackAccessToken(id: \$vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: \$playerType}) @include(if: \$isVod) { value signature __typename } }',
'variables': variables,
}),
);
}
return PlaybackAccessToken.fromGqlResponse(response);
}
/// Builds the HLS stream URL for the given channel [login] and [token].
String buildHlsUrl({
required String login,
required PlaybackAccessToken token,
}) {
final random = Random().nextInt(999999);
final encodedToken = Uri.encodeComponent(token.value);
// Usher v2 — what the web player and streamlink use today. Same query
// params and token; the multivariant playlist is standards-compliant
// (EXT-X-SESSION-DATA + STREAM-INF only, no EXT-X-MEDIA/TWITCH-INFO).
return 'https://usher.ttvnw.net/api/v2/channel/hls/$login.m3u8'
'?sig=${token.signature}'
'&token=$encodedToken'
'&allow_source=true'
'&allow_audio_only=true'
'&fast_bread=true'
'&supported_codecs=av1,h265,h264'
'&reassignments_supported=true'
'&platform=web'
'&player_backend=mediaplayer'
'&play_session_id=${_randomSessionId()}'
'&playlist_include_framerate=true'
'&p=$random';
}
/// 32-char hex play-session id, matching the web client and streamlink. Sent
/// to Usher to identify the playback session; Usher tolerates its absence
/// today but has historically tightened required params.
String _randomSessionId() {
final random = Random();
final buffer = StringBuffer();
for (var i = 0; i < 32; i++) {
buffer.write(random.nextInt(16).toRadixString(16));
}
return buffer.toString();
}
}

View file

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

View file

@ -3,6 +3,18 @@ import 'package:flutter/material.dart';
/// Twitch API client ID.
const clientId = String.fromEnvironment('CLIENT_ID');
/// Twitch GQL client ID (web player, used for playback access tokens).
const twitchGqlClientId = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
/// SharedPreferences key for the per-channel last-selected stream quality.
/// A saved variant that doesn't exist on the target channel (e.g. 1080p on a
/// source-only channel) falls back to Auto via the quality-list lookup.
String lastStreamQualityKey(String userLogin) =>
'last_stream_quality_$userLogin';
/// Legacy global key. Cleared on startup during SharedPreferences init.
const kLegacyLastStreamQualityKey = 'last_stream_quality';
/// Twitch API client secret.
const secret = String.fromEnvironment('SECRET');

View file

@ -17,8 +17,10 @@ import 'package:frosty/apis/ffz_api.dart';
import 'package:frosty/apis/seventv_api.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/apis/twitch_auth_interceptor.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:frosty/apis/unauthorized_interceptor.dart';
import 'package:frosty/cache_manager.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/firebase_options.dart';
import 'package:frosty/screens/channel/channel.dart';
import 'package:frosty/screens/home/home.dart';
@ -52,6 +54,12 @@ void main() async {
final prefs = await SharedPreferences.getInstance();
// One-time migration: the quality preference used to be stored under a
// single global key, which meant a saved 1080p60 from one channel could
// pin another channel to a variant it doesn't serve. Remove the legacy
// key; new writes go under per-channel keys.
await prefs.remove(kLegacyLastStreamQualityKey);
final firstRun = prefs.getBool('first_run') ?? true;
// Workaround for clearing stored tokens on uninstall.
@ -104,6 +112,7 @@ void main() async {
final bttvApiService = BTTVApi(dioClient);
final ffzApiService = FFZApi(dioClient);
final sevenTVApiService = SevenTVApi(dioClient);
final twitchGqlApiService = TwitchGqlApi(dioClient);
// Create global assets store (shared cache for global emotes/badges)
final globalAssetsStore = GlobalAssetsStore(
@ -137,6 +146,7 @@ void main() async {
Provider<BTTVApi>.value(value: bttvApiService),
Provider<FFZApi>.value(value: ffzApiService),
Provider<SevenTVApi>.value(value: sevenTVApiService),
Provider<TwitchGqlApi>.value(value: twitchGqlApiService),
Provider<GlobalAssetsStore>.value(value: globalAssetsStore),
],
child: MyApp(firstRun: firstRun),

View file

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

View file

@ -0,0 +1,65 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
class PlaybackAccessToken {
final String value;
final String signature;
/// Whether Twitch will suppress ads for this session (Turbo, sub, etc.).
/// Defaults to false when the token payload can't be parsed.
final bool hideAds;
/// Quality group names (e.g. "1080p60") that exist for this stream but are
/// restricted to subscribers, so they won't appear in the HLS playlist.
final List<String> restrictedQualities;
const PlaybackAccessToken({
required this.value,
required this.signature,
this.hideAds = false,
this.restrictedQualities = const [],
});
/// Names in restricted_bitrates that aren't real video qualities
/// (matches streamlink's filter).
static final _nonQualityNameRe = RegExp(r'^((.+_)?archives|live|chunked)');
factory PlaybackAccessToken.fromGqlResponse(Map<String, dynamic> json) {
final data =
(json['data'] as Map<String, dynamic>?)?['streamPlaybackAccessToken']
as Map<String, dynamic>?;
if (data == null) {
throw const FormatException(
'streamPlaybackAccessToken missing from GQL response',
);
}
final value = data['value'] as String;
var hideAds = false;
var restricted = const <String>[];
try {
final payload = jsonDecode(value) as Map<String, dynamic>;
hideAds = payload['hide_ads'] as bool? ?? false;
final bitrates =
(payload['chansub'] as Map<String, dynamic>?)?['restricted_bitrates']
as List<dynamic>?;
if (bitrates != null) {
restricted = bitrates
.whereType<String>()
.where((name) => !_nonQualityNameRe.hasMatch(name))
.toList();
}
} catch (e) {
// Token payload format is Twitch-internal — never fail playback over it.
debugPrint('PlaybackAccessToken: failed to parse token payload: $e');
}
return PlaybackAccessToken(
value: value,
signature: data['signature'] as String,
hideAds: hideAds,
restrictedQualities: restricted,
);
}
}

View file

@ -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,

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart';
import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart';
@ -11,6 +12,10 @@ import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/frosty_page_view.dart';
import 'package:frosty/widgets/frosty_scrollbar.dart';
/// ~2x default to keep richer message widgets (emotes, badges, replies) built
/// ahead of fast scroll-back without holding the entire history.
const _chatCacheExtent = ScrollCacheExtent.pixels(500.0);
class Chat extends StatelessWidget {
final ChatStore chatStore;
final EdgeInsetsGeometry? listPadding;
@ -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) {

View file

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

View file

@ -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.

View file

@ -55,6 +55,10 @@ class ChatTabInfo {
/// The ChatStore for this tab. Null until the tab is activated (lazy loading).
ChatStore? chatStore;
/// The id of the most recent message visible the last time the user viewed
/// this tab. Used to compute unread state — null means "no baseline yet".
String? lastSeenMessageId;
/// Whether this is the primary tab (first tab, cannot be removed).
final bool isPrimary;
@ -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.

View file

@ -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,
);
},
);

View file

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

View file

@ -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),
),
);
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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,
),
),
],
),
),
),
),
],
),
);
},
);
}
}

View 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);
}

File diff suppressed because it is too large Load diff

View 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}
''';
}
}

View file

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

View file

@ -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(

View 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

View file

@ -9,6 +9,26 @@ part of 'video_store.dart';
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
mixin _$VideoStore on VideoStoreBase, Store {
late final _$_loadingAtom = Atom(
name: 'VideoStoreBase._loading',
context: context,
);
bool get loading {
_$_loadingAtom.reportRead();
return super._loading;
}
@override
bool get _loading => loading;
@override
set _loading(bool value) {
_$_loadingAtom.reportWrite(value, super._loading, () {
super._loading = value;
});
}
late final _$_pausedAtom = Atom(
name: 'VideoStoreBase._paused',
context: context,

View file

@ -0,0 +1,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);
}

View file

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

View file

@ -5,6 +5,7 @@ import 'package:frosty/screens/onboarding/login_webview.dart';
import 'package:frosty/screens/settings/account/account_options.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:frosty/widgets/frosty_dialog.dart';
import 'package:frosty/widgets/profile_picture.dart';
class ProfileCard extends StatelessWidget {
@ -39,12 +40,69 @@ class ProfileCard extends StatelessWidget {
);
}
if (authStore.isLoggedIn && authStore.user.details != null) {
final hasToken = authStore.gqlToken != null;
return ListTile(
leading: ProfilePicture(
userLogin: authStore.user.details!.login,
radius: 12,
),
title: Text(authStore.user.details!.displayName),
title: GestureDetector(
onTap: () {
final navigator = Navigator.of(context);
showDialog(
context: context,
builder: (dialogContext) => FrostyDialog(
title: 'Web session',
message: hasToken
? 'Your Twitch web session is linked. When using the native player, ads will be avoided on channels where you have a subscription or Twitch Turbo.'
: 'Your Twitch web session is not linked. Log in again to avoid ads when using the native player on channels where you have a subscription or Twitch Turbo.',
actions: hasToken
? [
TextButton(
onPressed: Navigator.of(dialogContext).pop,
child: const Text('OK'),
),
]
: [
TextButton(
onPressed: Navigator.of(dialogContext).pop,
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
Navigator.of(dialogContext).pop();
navigator.push(
MaterialPageRoute(
builder: (context) => LoginWebView(),
),
);
},
child: const Text('Log in'),
),
],
),
);
},
child: Text.rich(
TextSpan(
text: authStore.user.details!.displayName,
children: [
const WidgetSpan(child: SizedBox(width: 6)),
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Icon(
hasToken
? Icons.check_circle_outline_rounded
: Icons.info_outline_rounded,
size: 16,
color: hasToken ? Colors.green : Colors.amber,
),
),
],
),
overflow: TextOverflow.ellipsis,
),
),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => _showAccountOptionsModalBottomSheet(context),
);

View file

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

View file

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

View file

@ -9,6 +9,7 @@ import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/main.dart';
import 'package:frosty/screens/settings/stores/user_store.dart';
import 'package:frosty/services/cookie_extractor.dart';
import 'package:frosty/widgets/frosty_dialog.dart';
import 'package:mobx/mobx.dart';
import 'package:webview_flutter/webview_flutter.dart';
@ -27,6 +28,9 @@ abstract class AuthBase with Store {
/// The shared_preferences key for the user token.
static const _userTokenKey = 'user_token';
/// The secure storage key for the GQL web cookie token.
static const _gqlTokenKey = 'gql_token';
/// The Twitch API service for making requests.
final TwitchApi twitchApi;
@ -49,6 +53,10 @@ abstract class AuthBase with Store {
@readonly
String? _token;
/// Web cookie token extracted from the login WebView for GQL ad-free playback.
@readonly
String? _gqlToken;
/// Whether the user is logged in or not.
@readonly
var _isLoggedIn = false;
@ -83,6 +91,7 @@ abstract class AuthBase with Store {
// When redirected to the redirect_uri, there will be another redirect to "https://www.twitch.tv/?no-reload=true".
// Checking for this will ensure that the user has automatically logged in to Twitch on the WebView itself.
if (request.url == 'https://www.twitch.tv/?no-reload=true') {
_extractGqlToken();
if (routeAfter != null) {
navigatorKey.currentState?.pop();
navigatorKey.currentState?.push(
@ -211,12 +220,36 @@ abstract class AuthBase with Store {
AuthBase({required this.twitchApi}) : user = UserStore(twitchApi: twitchApi);
Future<void> _extractGqlToken() async {
try {
final token = await CookieExtractor.extractTwitchAuthToken();
if (token != null) {
runInAction(() {
_gqlToken = token;
});
await _storage.write(key: _gqlTokenKey, value: token);
}
} catch (e) {
debugPrint('GQL token extraction failed: $e');
}
}
/// Clears the stored web session token after Twitch rejects it, so the
/// profile card reflects the unlinked state and future stream loads skip
/// the doomed authenticated request.
@action
Future<void> invalidateGqlToken() async {
_gqlToken = null;
await _storage.delete(key: _gqlTokenKey);
}
/// Initialize by retrieving a token if it does not already exist.
@action
Future<void> init() async {
try {
// Read and set the currently stored user token, if any.
_token = await _storage.read(key: _userTokenKey);
_gqlToken = await _storage.read(key: _gqlTokenKey);
// If the token does not exist, get the default token.
// Otherwise, log in.
@ -248,6 +281,10 @@ abstract class AuthBase with Store {
if (user.details != null) {
_isLoggedIn = true;
_stopReconnectLoop();
// Recover a missed web session link — the WebView cookie store may
// still hold a valid auth-token from a previous login.
if (_gqlToken == null) _extractGqlToken();
}
}
@ -293,9 +330,11 @@ abstract class AuthBase with Store {
Future<void> logout() async {
try {
_stopReconnectLoop();
// Delete the existing user token.
// Delete the existing user token and GQL token.
await _storage.delete(key: _userTokenKey);
await _storage.delete(key: _gqlTokenKey);
_token = null;
_gqlToken = null;
// Clear the user info.
user.dispose();

View file

@ -35,6 +35,26 @@ mixin _$AuthStore on AuthBase, Store {
});
}
late final _$_gqlTokenAtom = Atom(
name: 'AuthBase._gqlToken',
context: context,
);
String? get gqlToken {
_$_gqlTokenAtom.reportRead();
return super._gqlToken;
}
@override
String? get _gqlToken => gqlToken;
@override
set _gqlToken(String? value) {
_$_gqlTokenAtom.reportWrite(value, super._gqlToken, () {
super._gqlToken = value;
});
}
late final _$_isLoggedInAtom = Atom(
name: 'AuthBase._isLoggedIn',
context: context,
@ -72,6 +92,18 @@ mixin _$AuthStore on AuthBase, Store {
});
}
late final _$invalidateGqlTokenAsyncAction = AsyncAction(
'AuthBase.invalidateGqlToken',
context: context,
);
@override
Future<void> invalidateGqlToken() {
return _$invalidateGqlTokenAsyncAction.run(
() => super.invalidateGqlToken(),
);
}
late final _$initAsyncAction = AsyncAction('AuthBase.init', context: context);
@override

View file

@ -65,9 +65,8 @@ abstract class _SettingsStoreBase with Store {
static const defaultShowVideo = true;
static const defaultDefaultToHighestQuality = false;
static const defaultUseTextureRendering = true;
static const defaultKeepScreenAwake = true;
static const defaultUseNativePlayer = false;
// Overlay defaults
static const defaultShowOverlay = true;
static const defaultToggleableOverlay = false;
static const defaultShowLatency = false;
@ -85,9 +84,9 @@ abstract class _SettingsStoreBase with Store {
@observable
var useTextureRendering = defaultUseTextureRendering;
@JsonKey(defaultValue: defaultKeepScreenAwake)
@JsonKey(defaultValue: defaultUseNativePlayer)
@observable
var keepScreenAwake = defaultKeepScreenAwake;
var useNativePlayer = defaultUseNativePlayer;
// Overlay options
@JsonKey(defaultValue: defaultShowOverlay)
@ -107,7 +106,7 @@ abstract class _SettingsStoreBase with Store {
showVideo = defaultShowVideo;
defaultToHighestQuality = defaultDefaultToHighestQuality;
useTextureRendering = defaultUseTextureRendering;
keepScreenAwake = defaultKeepScreenAwake;
useNativePlayer = defaultUseNativePlayer;
showOverlay = defaultShowOverlay;
toggleableOverlay = defaultToggleableOverlay;
@ -115,44 +114,27 @@ abstract class _SettingsStoreBase with Store {
}
// * Chat Settings
// Message sizing defaults
// -- Messages --
// Sizing defaults
static const defaultBadgeScale = 1.0;
static const defaultEmoteScale = 1.0;
static const defaultMessageScale = 1.0;
static const defaultMessageSpacing = 8.0;
static const defaultFontSize = 12.0;
// Message appearance defaults
// Appearance defaults
static const defaultShowDeletedMessages = false;
static const defaultShowChatMessageDividers = false;
static const defaultTimestampType = TimestampType.disabled;
// Delay defaults
static const defaultAutoSyncChatDelay = false;
static const defaultChatDelay = 0.0;
static const defaultShowHistoricalTimestamps = false;
static const defaultFocusCurrentChannel = false;
// Alert defaults
static const defaultHighlightFirstTimeChatter = true;
static const defaultShowUserNotices = true;
// Layout defaults
static const defaultEmoteMenuButtonOnLeft = false;
// Landscape mode defaults
static const defaultLandscapeChatLeftSide = false;
static const defaultLandscapeForceVerticalChat = false;
static const defaultLandscapeCutout = LandscapeCutoutType.none;
static const defaultChatWidth = 0.2;
static const defaultFullScreenChatOverlayOpacity = 0.5;
// mute words defaults
static const defaultMutedWords = <String>[];
static const defaultMatchWholeWord = true;
// Autocomplete defaults
static const defaultAutocomplete = true;
// Emotes and badges defaults
// -- Emotes & Badges --
static const defaultShowTwitchEmotes = true;
static const defaultShowTwitchBadges = true;
static const defaultShow7TVEmotes = true;
@ -161,14 +143,26 @@ abstract class _SettingsStoreBase with Store {
static const defaultShowFFZEmotes = true;
static const defaultShowFFZBadges = true;
// Recent messages defaults
static const defaultShowRecentMessages = false;
// Chat tabs defaults
// -- Layout --
static const defaultEmoteMenuButtonOnLeft = false;
static const defaultPersistChatTabs = true;
static const defaultSecondaryTabs = <PersistedChatTab>[];
static const defaultLandscapeChatLeftSide = false;
static const defaultLandscapeForceVerticalChat = false;
static const defaultLandscapeCutout = LandscapeCutoutType.none;
static const defaultChatWidth = 0.2;
static const defaultFullScreenChatOverlayOpacity = 0.5;
// Message sizing options
// -- Behavior --
static const defaultKeepScreenAwake = true;
static const defaultAutocomplete = true;
static const defaultShowRecentMessages = false;
static const defaultAutoSyncChatDelay = false;
static const defaultChatDelay = 0.0;
static const defaultMutedWords = <String>[];
static const defaultMatchWholeWord = true;
// Messages options
@JsonKey(defaultValue: defaultBadgeScale)
@observable
var badgeScale = defaultBadgeScale;
@ -189,7 +183,6 @@ abstract class _SettingsStoreBase with Store {
@observable
var fontSize = defaultFontSize;
// Message appearance options
@JsonKey(defaultValue: defaultShowDeletedMessages)
@observable
var showDeletedMessages = defaultShowDeletedMessages;
@ -205,24 +198,14 @@ abstract class _SettingsStoreBase with Store {
@observable
var timestampType = defaultTimestampType;
// Delay options
@JsonKey(defaultValue: defaultAutoSyncChatDelay)
@JsonKey(defaultValue: defaultShowHistoricalTimestamps)
@observable
var autoSyncChatDelay = defaultAutoSyncChatDelay;
var showHistoricalTimestamps = defaultShowHistoricalTimestamps;
@JsonKey(defaultValue: defaultChatDelay)
@JsonKey(defaultValue: defaultFocusCurrentChannel)
@observable
var chatDelay = defaultChatDelay;
var focusCurrentChannel = defaultFocusCurrentChannel;
@JsonKey(includeFromJson: false, includeToJson: false)
@observable
var syncedChatDelay = defaultChatDelay;
@computed
double get effectiveChatDelay =>
autoSyncChatDelay ? syncedChatDelay : chatDelay;
// Alert options
@JsonKey(defaultValue: defaultHighlightFirstTimeChatter)
@observable
var highlightFirstTimeChatter = defaultHighlightFirstTimeChatter;
@ -231,38 +214,7 @@ abstract class _SettingsStoreBase with Store {
@observable
var showUserNotices = defaultShowUserNotices;
// Layout options
@JsonKey(defaultValue: defaultEmoteMenuButtonOnLeft)
@observable
var emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft;
// Landscape mode options
@JsonKey(defaultValue: defaultLandscapeChatLeftSide)
@observable
var landscapeChatLeftSide = defaultLandscapeChatLeftSide;
@JsonKey(defaultValue: defaultLandscapeForceVerticalChat)
@observable
var landscapeForceVerticalChat = defaultLandscapeForceVerticalChat;
@JsonKey(defaultValue: defaultLandscapeCutout)
@observable
var landscapeCutout = defaultLandscapeCutout;
@JsonKey(defaultValue: defaultChatWidth)
@observable
var chatWidth = defaultChatWidth;
@JsonKey(defaultValue: defaultFullScreenChatOverlayOpacity)
@observable
var fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity;
// Autocomplete options
@JsonKey(defaultValue: defaultAutocomplete)
@observable
var autocomplete = defaultAutocomplete;
// Emotes and badges
// Emotes & Badges options
@JsonKey(defaultValue: defaultShowTwitchEmotes)
@observable
var showTwitchEmotes = defaultShowTwitchEmotes;
@ -291,12 +243,11 @@ abstract class _SettingsStoreBase with Store {
@observable
var showFFZBadges = defaultShowFFZBadges;
// Recent messages
@JsonKey(defaultValue: defaultShowRecentMessages)
// Layout options
@JsonKey(defaultValue: defaultEmoteMenuButtonOnLeft)
@observable
var showRecentMessages = defaultShowRecentMessages;
var emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft;
// Chat tabs
@JsonKey(defaultValue: defaultPersistChatTabs)
@observable
var persistChatTabs = defaultPersistChatTabs;
@ -305,6 +256,55 @@ abstract class _SettingsStoreBase with Store {
@observable
var secondaryTabs = defaultSecondaryTabs;
@JsonKey(defaultValue: defaultLandscapeChatLeftSide)
@observable
var landscapeChatLeftSide = defaultLandscapeChatLeftSide;
@JsonKey(defaultValue: defaultLandscapeForceVerticalChat)
@observable
var landscapeForceVerticalChat = defaultLandscapeForceVerticalChat;
@JsonKey(defaultValue: defaultLandscapeCutout)
@observable
var landscapeCutout = defaultLandscapeCutout;
@JsonKey(defaultValue: defaultChatWidth)
@observable
var chatWidth = defaultChatWidth;
@JsonKey(defaultValue: defaultFullScreenChatOverlayOpacity)
@observable
var fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity;
// Behavior options
@JsonKey(defaultValue: defaultKeepScreenAwake)
@observable
var keepScreenAwake = defaultKeepScreenAwake;
@JsonKey(defaultValue: defaultAutocomplete)
@observable
var autocomplete = defaultAutocomplete;
@JsonKey(defaultValue: defaultShowRecentMessages)
@observable
var showRecentMessages = defaultShowRecentMessages;
@JsonKey(defaultValue: defaultAutoSyncChatDelay)
@observable
var autoSyncChatDelay = defaultAutoSyncChatDelay;
@JsonKey(defaultValue: defaultChatDelay)
@observable
var chatDelay = defaultChatDelay;
@JsonKey(includeFromJson: false, includeToJson: false)
@observable
var syncedChatDelay = defaultChatDelay;
@computed
double get effectiveChatDelay =>
autoSyncChatDelay ? syncedChatDelay : chatDelay;
@JsonKey(defaultValue: defaultMutedWords)
@observable
List<String> mutedWords = defaultMutedWords;
@ -315,36 +315,21 @@ abstract class _SettingsStoreBase with Store {
@action
void resetChatSettings() {
// Messages
badgeScale = defaultBadgeScale;
emoteScale = defaultEmoteScale;
messageScale = defaultMessageScale;
messageSpacing = defaultMessageSpacing;
fontSize = defaultFontSize;
showDeletedMessages = defaultShowDeletedMessages;
showChatMessageDividers = defaultShowChatMessageDividers;
timestampType = defaultTimestampType;
autoSyncChatDelay = defaultAutoSyncChatDelay;
chatDelay = defaultChatDelay;
syncedChatDelay = defaultChatDelay;
showHistoricalTimestamps = defaultShowHistoricalTimestamps;
focusCurrentChannel = defaultFocusCurrentChannel;
highlightFirstTimeChatter = defaultHighlightFirstTimeChatter;
showUserNotices = defaultShowUserNotices;
emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft;
landscapeChatLeftSide = defaultLandscapeChatLeftSide;
landscapeForceVerticalChat = defaultLandscapeForceVerticalChat;
landscapeCutout = defaultLandscapeCutout;
chatWidth = defaultChatWidth;
fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity;
mutedWords = defaultMutedWords;
matchWholeWord = defaultMatchWholeWord;
autocomplete = defaultAutocomplete;
// Emotes & Badges
showTwitchEmotes = defaultShowTwitchEmotes;
showTwitchBadges = defaultShowTwitchBadges;
show7TVEmotes = defaultShow7TVEmotes;
@ -353,10 +338,25 @@ abstract class _SettingsStoreBase with Store {
showFFZEmotes = defaultShowFFZEmotes;
showFFZBadges = defaultShowFFZBadges;
showRecentMessages = defaultShowRecentMessages;
// Layout
emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft;
persistChatTabs = defaultPersistChatTabs;
secondaryTabs = defaultSecondaryTabs;
landscapeChatLeftSide = defaultLandscapeChatLeftSide;
landscapeForceVerticalChat = defaultLandscapeForceVerticalChat;
landscapeCutout = defaultLandscapeCutout;
chatWidth = defaultChatWidth;
fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity;
// Behavior
keepScreenAwake = defaultKeepScreenAwake;
autocomplete = defaultAutocomplete;
showRecentMessages = defaultShowRecentMessages;
autoSyncChatDelay = defaultAutoSyncChatDelay;
chatDelay = defaultChatDelay;
syncedChatDelay = defaultChatDelay;
mutedWords = defaultMutedWords;
matchWholeWord = defaultMatchWholeWord;
}
// * Other settings

View file

@ -23,7 +23,7 @@ SettingsStore _$SettingsStoreFromJson(
..showVideo = json['showVideo'] as bool? ?? true
..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false
..useTextureRendering = json['useTextureRendering'] as bool? ?? true
..keepScreenAwake = json['keepScreenAwake'] as bool? ?? true
..useNativePlayer = json['useNativePlayer'] as bool? ?? false
..showOverlay = json['showOverlay'] as bool? ?? true
..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false
..showLatency = json['showLatency'] as bool? ?? false
@ -41,12 +41,26 @@ SettingsStore _$SettingsStoreFromJson(
unknownValue: TimestampType.disabled,
) ??
TimestampType.disabled
..autoSyncChatDelay = json['autoSyncChatDelay'] as bool? ?? false
..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0
..showHistoricalTimestamps =
json['showHistoricalTimestamps'] as bool? ?? false
..focusCurrentChannel = json['focusCurrentChannel'] as bool? ?? false
..highlightFirstTimeChatter =
json['highlightFirstTimeChatter'] as bool? ?? true
..showUserNotices = json['showUserNotices'] as bool? ?? true
..showTwitchEmotes = json['showTwitchEmotes'] as bool? ?? true
..showTwitchBadges = json['showTwitchBadges'] as bool? ?? true
..show7TVEmotes = json['show7TVEmotes'] as bool? ?? true
..showBTTVEmotes = json['showBTTVEmotes'] as bool? ?? true
..showBTTVBadges = json['showBTTVBadges'] as bool? ?? true
..showFFZEmotes = json['showFFZEmotes'] as bool? ?? true
..showFFZBadges = json['showFFZBadges'] as bool? ?? true
..emoteMenuButtonOnLeft = json['emoteMenuButtonOnLeft'] as bool? ?? false
..persistChatTabs = json['persistChatTabs'] as bool? ?? true
..secondaryTabs =
(json['secondaryTabs'] as List<dynamic>?)
?.map((e) => PersistedChatTab.fromJson(e as Map<String, dynamic>))
.toList() ??
[]
..landscapeChatLeftSide = json['landscapeChatLeftSide'] as bool? ?? false
..landscapeForceVerticalChat =
json['landscapeForceVerticalChat'] as bool? ?? false
@ -59,21 +73,11 @@ SettingsStore _$SettingsStoreFromJson(
..chatWidth = (json['chatWidth'] as num?)?.toDouble() ?? 0.2
..fullScreenChatOverlayOpacity =
(json['fullScreenChatOverlayOpacity'] as num?)?.toDouble() ?? 0.5
..keepScreenAwake = json['keepScreenAwake'] as bool? ?? true
..autocomplete = json['autocomplete'] as bool? ?? true
..showTwitchEmotes = json['showTwitchEmotes'] as bool? ?? true
..showTwitchBadges = json['showTwitchBadges'] as bool? ?? true
..show7TVEmotes = json['show7TVEmotes'] as bool? ?? true
..showBTTVEmotes = json['showBTTVEmotes'] as bool? ?? true
..showBTTVBadges = json['showBTTVBadges'] as bool? ?? true
..showFFZEmotes = json['showFFZEmotes'] as bool? ?? true
..showFFZBadges = json['showFFZBadges'] as bool? ?? true
..showRecentMessages = json['showRecentMessages'] as bool? ?? false
..persistChatTabs = json['persistChatTabs'] as bool? ?? true
..secondaryTabs =
(json['secondaryTabs'] as List<dynamic>?)
?.map((e) => PersistedChatTab.fromJson(e as Map<String, dynamic>))
.toList() ??
[]
..autoSyncChatDelay = json['autoSyncChatDelay'] as bool? ?? false
..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0
..mutedWords =
(json['mutedWords'] as List<dynamic>?)
?.map((e) => e as String)
@ -101,7 +105,7 @@ Map<String, dynamic> _$SettingsStoreToJson(
'showVideo': instance.showVideo,
'defaultToHighestQuality': instance.defaultToHighestQuality,
'useTextureRendering': instance.useTextureRendering,
'keepScreenAwake': instance.keepScreenAwake,
'useNativePlayer': instance.useNativePlayer,
'showOverlay': instance.showOverlay,
'toggleableOverlay': instance.toggleableOverlay,
'showLatency': instance.showLatency,
@ -113,17 +117,10 @@ Map<String, dynamic> _$SettingsStoreToJson(
'showDeletedMessages': instance.showDeletedMessages,
'showChatMessageDividers': instance.showChatMessageDividers,
'timestampType': _$TimestampTypeEnumMap[instance.timestampType]!,
'autoSyncChatDelay': instance.autoSyncChatDelay,
'chatDelay': instance.chatDelay,
'showHistoricalTimestamps': instance.showHistoricalTimestamps,
'focusCurrentChannel': instance.focusCurrentChannel,
'highlightFirstTimeChatter': instance.highlightFirstTimeChatter,
'showUserNotices': instance.showUserNotices,
'emoteMenuButtonOnLeft': instance.emoteMenuButtonOnLeft,
'landscapeChatLeftSide': instance.landscapeChatLeftSide,
'landscapeForceVerticalChat': instance.landscapeForceVerticalChat,
'landscapeCutout': _$LandscapeCutoutTypeEnumMap[instance.landscapeCutout]!,
'chatWidth': instance.chatWidth,
'fullScreenChatOverlayOpacity': instance.fullScreenChatOverlayOpacity,
'autocomplete': instance.autocomplete,
'showTwitchEmotes': instance.showTwitchEmotes,
'showTwitchBadges': instance.showTwitchBadges,
'show7TVEmotes': instance.show7TVEmotes,
@ -131,9 +128,19 @@ Map<String, dynamic> _$SettingsStoreToJson(
'showBTTVBadges': instance.showBTTVBadges,
'showFFZEmotes': instance.showFFZEmotes,
'showFFZBadges': instance.showFFZBadges,
'showRecentMessages': instance.showRecentMessages,
'emoteMenuButtonOnLeft': instance.emoteMenuButtonOnLeft,
'persistChatTabs': instance.persistChatTabs,
'secondaryTabs': instance.secondaryTabs,
'landscapeChatLeftSide': instance.landscapeChatLeftSide,
'landscapeForceVerticalChat': instance.landscapeForceVerticalChat,
'landscapeCutout': _$LandscapeCutoutTypeEnumMap[instance.landscapeCutout]!,
'chatWidth': instance.chatWidth,
'fullScreenChatOverlayOpacity': instance.fullScreenChatOverlayOpacity,
'keepScreenAwake': instance.keepScreenAwake,
'autocomplete': instance.autocomplete,
'showRecentMessages': instance.showRecentMessages,
'autoSyncChatDelay': instance.autoSyncChatDelay,
'chatDelay': instance.chatDelay,
'mutedWords': instance.mutedWords,
'matchWholeWord': instance.matchWholeWord,
'shareCrashLogsAndAnalytics': instance.shareCrashLogsAndAnalytics,
@ -325,21 +332,21 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
late final _$keepScreenAwakeAtom = Atom(
name: '_SettingsStoreBase.keepScreenAwake',
late final _$useNativePlayerAtom = Atom(
name: '_SettingsStoreBase.useNativePlayer',
context: context,
);
@override
bool get keepScreenAwake {
_$keepScreenAwakeAtom.reportRead();
return super.keepScreenAwake;
bool get useNativePlayer {
_$useNativePlayerAtom.reportRead();
return super.useNativePlayer;
}
@override
set keepScreenAwake(bool value) {
_$keepScreenAwakeAtom.reportWrite(value, super.keepScreenAwake, () {
super.keepScreenAwake = value;
set useNativePlayer(bool value) {
_$useNativePlayerAtom.reportWrite(value, super.useNativePlayer, () {
super.useNativePlayer = value;
});
}
@ -545,57 +552,43 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
late final _$autoSyncChatDelayAtom = Atom(
name: '_SettingsStoreBase.autoSyncChatDelay',
late final _$showHistoricalTimestampsAtom = Atom(
name: '_SettingsStoreBase.showHistoricalTimestamps',
context: context,
);
@override
bool get autoSyncChatDelay {
_$autoSyncChatDelayAtom.reportRead();
return super.autoSyncChatDelay;
bool get showHistoricalTimestamps {
_$showHistoricalTimestampsAtom.reportRead();
return super.showHistoricalTimestamps;
}
@override
set autoSyncChatDelay(bool value) {
_$autoSyncChatDelayAtom.reportWrite(value, super.autoSyncChatDelay, () {
super.autoSyncChatDelay = value;
});
set showHistoricalTimestamps(bool value) {
_$showHistoricalTimestampsAtom.reportWrite(
value,
super.showHistoricalTimestamps,
() {
super.showHistoricalTimestamps = value;
},
);
}
late final _$chatDelayAtom = Atom(
name: '_SettingsStoreBase.chatDelay',
late final _$focusCurrentChannelAtom = Atom(
name: '_SettingsStoreBase.focusCurrentChannel',
context: context,
);
@override
double get chatDelay {
_$chatDelayAtom.reportRead();
return super.chatDelay;
bool get focusCurrentChannel {
_$focusCurrentChannelAtom.reportRead();
return super.focusCurrentChannel;
}
@override
set chatDelay(double value) {
_$chatDelayAtom.reportWrite(value, super.chatDelay, () {
super.chatDelay = value;
});
}
late final _$syncedChatDelayAtom = Atom(
name: '_SettingsStoreBase.syncedChatDelay',
context: context,
);
@override
double get syncedChatDelay {
_$syncedChatDelayAtom.reportRead();
return super.syncedChatDelay;
}
@override
set syncedChatDelay(double value) {
_$syncedChatDelayAtom.reportWrite(value, super.syncedChatDelay, () {
super.syncedChatDelay = value;
set focusCurrentChannel(bool value) {
_$focusCurrentChannelAtom.reportWrite(value, super.focusCurrentChannel, () {
super.focusCurrentChannel = value;
});
}
@ -639,148 +632,6 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
late final _$emoteMenuButtonOnLeftAtom = Atom(
name: '_SettingsStoreBase.emoteMenuButtonOnLeft',
context: context,
);
@override
bool get emoteMenuButtonOnLeft {
_$emoteMenuButtonOnLeftAtom.reportRead();
return super.emoteMenuButtonOnLeft;
}
@override
set emoteMenuButtonOnLeft(bool value) {
_$emoteMenuButtonOnLeftAtom.reportWrite(
value,
super.emoteMenuButtonOnLeft,
() {
super.emoteMenuButtonOnLeft = value;
},
);
}
late final _$landscapeChatLeftSideAtom = Atom(
name: '_SettingsStoreBase.landscapeChatLeftSide',
context: context,
);
@override
bool get landscapeChatLeftSide {
_$landscapeChatLeftSideAtom.reportRead();
return super.landscapeChatLeftSide;
}
@override
set landscapeChatLeftSide(bool value) {
_$landscapeChatLeftSideAtom.reportWrite(
value,
super.landscapeChatLeftSide,
() {
super.landscapeChatLeftSide = value;
},
);
}
late final _$landscapeForceVerticalChatAtom = Atom(
name: '_SettingsStoreBase.landscapeForceVerticalChat',
context: context,
);
@override
bool get landscapeForceVerticalChat {
_$landscapeForceVerticalChatAtom.reportRead();
return super.landscapeForceVerticalChat;
}
@override
set landscapeForceVerticalChat(bool value) {
_$landscapeForceVerticalChatAtom.reportWrite(
value,
super.landscapeForceVerticalChat,
() {
super.landscapeForceVerticalChat = value;
},
);
}
late final _$landscapeCutoutAtom = Atom(
name: '_SettingsStoreBase.landscapeCutout',
context: context,
);
@override
LandscapeCutoutType get landscapeCutout {
_$landscapeCutoutAtom.reportRead();
return super.landscapeCutout;
}
@override
set landscapeCutout(LandscapeCutoutType value) {
_$landscapeCutoutAtom.reportWrite(value, super.landscapeCutout, () {
super.landscapeCutout = value;
});
}
late final _$chatWidthAtom = Atom(
name: '_SettingsStoreBase.chatWidth',
context: context,
);
@override
double get chatWidth {
_$chatWidthAtom.reportRead();
return super.chatWidth;
}
@override
set chatWidth(double value) {
_$chatWidthAtom.reportWrite(value, super.chatWidth, () {
super.chatWidth = value;
});
}
late final _$fullScreenChatOverlayOpacityAtom = Atom(
name: '_SettingsStoreBase.fullScreenChatOverlayOpacity',
context: context,
);
@override
double get fullScreenChatOverlayOpacity {
_$fullScreenChatOverlayOpacityAtom.reportRead();
return super.fullScreenChatOverlayOpacity;
}
@override
set fullScreenChatOverlayOpacity(double value) {
_$fullScreenChatOverlayOpacityAtom.reportWrite(
value,
super.fullScreenChatOverlayOpacity,
() {
super.fullScreenChatOverlayOpacity = value;
},
);
}
late final _$autocompleteAtom = Atom(
name: '_SettingsStoreBase.autocomplete',
context: context,
);
@override
bool get autocomplete {
_$autocompleteAtom.reportRead();
return super.autocomplete;
}
@override
set autocomplete(bool value) {
_$autocompleteAtom.reportWrite(value, super.autocomplete, () {
super.autocomplete = value;
});
}
late final _$showTwitchEmotesAtom = Atom(
name: '_SettingsStoreBase.showTwitchEmotes',
context: context,
@ -907,22 +758,26 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
late final _$showRecentMessagesAtom = Atom(
name: '_SettingsStoreBase.showRecentMessages',
late final _$emoteMenuButtonOnLeftAtom = Atom(
name: '_SettingsStoreBase.emoteMenuButtonOnLeft',
context: context,
);
@override
bool get showRecentMessages {
_$showRecentMessagesAtom.reportRead();
return super.showRecentMessages;
bool get emoteMenuButtonOnLeft {
_$emoteMenuButtonOnLeftAtom.reportRead();
return super.emoteMenuButtonOnLeft;
}
@override
set showRecentMessages(bool value) {
_$showRecentMessagesAtom.reportWrite(value, super.showRecentMessages, () {
super.showRecentMessages = value;
});
set emoteMenuButtonOnLeft(bool value) {
_$emoteMenuButtonOnLeftAtom.reportWrite(
value,
super.emoteMenuButtonOnLeft,
() {
super.emoteMenuButtonOnLeft = value;
},
);
}
late final _$persistChatTabsAtom = Atom(
@ -961,6 +816,216 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
late final _$landscapeChatLeftSideAtom = Atom(
name: '_SettingsStoreBase.landscapeChatLeftSide',
context: context,
);
@override
bool get landscapeChatLeftSide {
_$landscapeChatLeftSideAtom.reportRead();
return super.landscapeChatLeftSide;
}
@override
set landscapeChatLeftSide(bool value) {
_$landscapeChatLeftSideAtom.reportWrite(
value,
super.landscapeChatLeftSide,
() {
super.landscapeChatLeftSide = value;
},
);
}
late final _$landscapeForceVerticalChatAtom = Atom(
name: '_SettingsStoreBase.landscapeForceVerticalChat',
context: context,
);
@override
bool get landscapeForceVerticalChat {
_$landscapeForceVerticalChatAtom.reportRead();
return super.landscapeForceVerticalChat;
}
@override
set landscapeForceVerticalChat(bool value) {
_$landscapeForceVerticalChatAtom.reportWrite(
value,
super.landscapeForceVerticalChat,
() {
super.landscapeForceVerticalChat = value;
},
);
}
late final _$landscapeCutoutAtom = Atom(
name: '_SettingsStoreBase.landscapeCutout',
context: context,
);
@override
LandscapeCutoutType get landscapeCutout {
_$landscapeCutoutAtom.reportRead();
return super.landscapeCutout;
}
@override
set landscapeCutout(LandscapeCutoutType value) {
_$landscapeCutoutAtom.reportWrite(value, super.landscapeCutout, () {
super.landscapeCutout = value;
});
}
late final _$chatWidthAtom = Atom(
name: '_SettingsStoreBase.chatWidth',
context: context,
);
@override
double get chatWidth {
_$chatWidthAtom.reportRead();
return super.chatWidth;
}
@override
set chatWidth(double value) {
_$chatWidthAtom.reportWrite(value, super.chatWidth, () {
super.chatWidth = value;
});
}
late final _$fullScreenChatOverlayOpacityAtom = Atom(
name: '_SettingsStoreBase.fullScreenChatOverlayOpacity',
context: context,
);
@override
double get fullScreenChatOverlayOpacity {
_$fullScreenChatOverlayOpacityAtom.reportRead();
return super.fullScreenChatOverlayOpacity;
}
@override
set fullScreenChatOverlayOpacity(double value) {
_$fullScreenChatOverlayOpacityAtom.reportWrite(
value,
super.fullScreenChatOverlayOpacity,
() {
super.fullScreenChatOverlayOpacity = value;
},
);
}
late final _$keepScreenAwakeAtom = Atom(
name: '_SettingsStoreBase.keepScreenAwake',
context: context,
);
@override
bool get keepScreenAwake {
_$keepScreenAwakeAtom.reportRead();
return super.keepScreenAwake;
}
@override
set keepScreenAwake(bool value) {
_$keepScreenAwakeAtom.reportWrite(value, super.keepScreenAwake, () {
super.keepScreenAwake = value;
});
}
late final _$autocompleteAtom = Atom(
name: '_SettingsStoreBase.autocomplete',
context: context,
);
@override
bool get autocomplete {
_$autocompleteAtom.reportRead();
return super.autocomplete;
}
@override
set autocomplete(bool value) {
_$autocompleteAtom.reportWrite(value, super.autocomplete, () {
super.autocomplete = value;
});
}
late final _$showRecentMessagesAtom = Atom(
name: '_SettingsStoreBase.showRecentMessages',
context: context,
);
@override
bool get showRecentMessages {
_$showRecentMessagesAtom.reportRead();
return super.showRecentMessages;
}
@override
set showRecentMessages(bool value) {
_$showRecentMessagesAtom.reportWrite(value, super.showRecentMessages, () {
super.showRecentMessages = value;
});
}
late final _$autoSyncChatDelayAtom = Atom(
name: '_SettingsStoreBase.autoSyncChatDelay',
context: context,
);
@override
bool get autoSyncChatDelay {
_$autoSyncChatDelayAtom.reportRead();
return super.autoSyncChatDelay;
}
@override
set autoSyncChatDelay(bool value) {
_$autoSyncChatDelayAtom.reportWrite(value, super.autoSyncChatDelay, () {
super.autoSyncChatDelay = value;
});
}
late final _$chatDelayAtom = Atom(
name: '_SettingsStoreBase.chatDelay',
context: context,
);
@override
double get chatDelay {
_$chatDelayAtom.reportRead();
return super.chatDelay;
}
@override
set chatDelay(double value) {
_$chatDelayAtom.reportWrite(value, super.chatDelay, () {
super.chatDelay = value;
});
}
late final _$syncedChatDelayAtom = Atom(
name: '_SettingsStoreBase.syncedChatDelay',
context: context,
);
@override
double get syncedChatDelay {
_$syncedChatDelayAtom.reportRead();
return super.syncedChatDelay;
}
@override
set syncedChatDelay(double value) {
_$syncedChatDelayAtom.reportWrite(value, super.syncedChatDelay, () {
super.syncedChatDelay = value;
});
}
late final _$mutedWordsAtom = Atom(
name: '_SettingsStoreBase.mutedWords',
context: context,
@ -1165,7 +1230,7 @@ launchUrlExternal: ${launchUrlExternal},
showVideo: ${showVideo},
defaultToHighestQuality: ${defaultToHighestQuality},
useTextureRendering: ${useTextureRendering},
keepScreenAwake: ${keepScreenAwake},
useNativePlayer: ${useNativePlayer},
showOverlay: ${showOverlay},
toggleableOverlay: ${toggleableOverlay},
showLatency: ${showLatency},
@ -1177,18 +1242,10 @@ fontSize: ${fontSize},
showDeletedMessages: ${showDeletedMessages},
showChatMessageDividers: ${showChatMessageDividers},
timestampType: ${timestampType},
autoSyncChatDelay: ${autoSyncChatDelay},
chatDelay: ${chatDelay},
syncedChatDelay: ${syncedChatDelay},
showHistoricalTimestamps: ${showHistoricalTimestamps},
focusCurrentChannel: ${focusCurrentChannel},
highlightFirstTimeChatter: ${highlightFirstTimeChatter},
showUserNotices: ${showUserNotices},
emoteMenuButtonOnLeft: ${emoteMenuButtonOnLeft},
landscapeChatLeftSide: ${landscapeChatLeftSide},
landscapeForceVerticalChat: ${landscapeForceVerticalChat},
landscapeCutout: ${landscapeCutout},
chatWidth: ${chatWidth},
fullScreenChatOverlayOpacity: ${fullScreenChatOverlayOpacity},
autocomplete: ${autocomplete},
showTwitchEmotes: ${showTwitchEmotes},
showTwitchBadges: ${showTwitchBadges},
show7TVEmotes: ${show7TVEmotes},
@ -1196,9 +1253,20 @@ showBTTVEmotes: ${showBTTVEmotes},
showBTTVBadges: ${showBTTVBadges},
showFFZEmotes: ${showFFZEmotes},
showFFZBadges: ${showFFZBadges},
showRecentMessages: ${showRecentMessages},
emoteMenuButtonOnLeft: ${emoteMenuButtonOnLeft},
persistChatTabs: ${persistChatTabs},
secondaryTabs: ${secondaryTabs},
landscapeChatLeftSide: ${landscapeChatLeftSide},
landscapeForceVerticalChat: ${landscapeForceVerticalChat},
landscapeCutout: ${landscapeCutout},
chatWidth: ${chatWidth},
fullScreenChatOverlayOpacity: ${fullScreenChatOverlayOpacity},
keepScreenAwake: ${keepScreenAwake},
autocomplete: ${autocomplete},
showRecentMessages: ${showRecentMessages},
autoSyncChatDelay: ${autoSyncChatDelay},
chatDelay: ${chatDelay},
syncedChatDelay: ${syncedChatDelay},
mutedWords: ${mutedWords},
matchWholeWord: ${matchWholeWord},
shareCrashLogsAndAnalytics: ${shareCrashLogsAndAnalytics},

View file

@ -20,10 +20,20 @@ class VideoSettings extends StatelessWidget {
children: [
const SectionHeader('Player', isFirst: true),
SettingsListSwitch(
title: 'Enable video',
title: 'Show video player',
value: settingsStore.showVideo,
onChanged: (newValue) => settingsStore.showVideo = newValue,
),
if (settingsStore.showVideo)
SettingsListSwitch(
title: 'Native player (experimental)',
subtitle: const Text(
'More performant video player with auto picture-in-picture and quality selection.',
),
value: settingsStore.useNativePlayer,
onChanged: (newValue) =>
settingsStore.useNativePlayer = newValue,
),
if (!Platform.isIOS || isIPad())
SettingsListSwitch(
title: 'Default to highest quality',
@ -41,14 +51,6 @@ class VideoSettings extends StatelessWidget {
onChanged: (newValue) =>
settingsStore.useTextureRendering = newValue,
),
SettingsListSwitch(
title: 'Keep screen awake',
subtitle: const Text(
'Prevents the screen from sleeping while a channel is open.',
),
value: settingsStore.keepScreenAwake,
onChanged: (newValue) => settingsStore.keepScreenAwake = newValue,
),
const SectionHeader('Overlay'),
SettingsListSwitch(
title: 'Use custom video overlay',
@ -59,12 +61,13 @@ class VideoSettings extends StatelessWidget {
onChanged: (newValue) => settingsStore.showOverlay = newValue,
),
SettingsListSwitch(
title: 'Long-press player to toggle overlay',
title: 'Toggle overlay on long-press',
subtitle: const Text(
'Allows switching between Twitch\'s overlay and the custom overlay.',
'Switch between Twitch\'s overlay and the custom overlay.',
),
value: settingsStore.toggleableOverlay,
onChanged: (newValue) => settingsStore.toggleableOverlay = newValue,
onChanged: (newValue) =>
settingsStore.toggleableOverlay = newValue,
),
SettingsListSwitch(
title: 'Show latency',

View file

@ -0,0 +1,9 @@
import 'package:flutter/services.dart';
class CookieExtractor {
static const _channel = MethodChannel('frosty/cookie_extractor');
static Future<String?> extractTwitchAuthToken() async {
return await _channel.invokeMethod<String>('extractTwitchAuthToken');
}
}

View file

@ -39,23 +39,23 @@ class FrostyColors extends ThemeExtension<FrostyColors> {
required this.overlayOnSurface,
});
static const light = FrostyColors(
highlightedMessage: Color(0xff9146ff),
emoteOnly: Color(0xFFFFB74D),
followersOnly: Color(0xFFF44336),
slowMode: Color(0xFF2196F3),
subOnly: Color(0xFF4CAF50),
uniqueMode: Color(0xFFAB47BC),
static FrostyColors light({required ColorScheme colorScheme}) => FrostyColors(
highlightedMessage: colorScheme.primary,
emoteOnly: const Color(0xFFFFB74D),
followersOnly: const Color(0xFFF44336),
slowMode: const Color(0xFF2196F3),
subOnly: const Color(0xFF4CAF50),
uniqueMode: const Color(0xFFAB47BC),
overlayOnSurface: Colors.white,
);
static const dark = FrostyColors(
highlightedMessage: Color(0xff9146ff),
emoteOnly: Color(0xFFFFCC80),
followersOnly: Color(0xFFE57373),
slowMode: Color(0xFF64B5F6),
subOnly: Color(0xFF81C784),
uniqueMode: Color(0xFFBA68C8),
static FrostyColors dark({required ColorScheme colorScheme}) => FrostyColors(
highlightedMessage: colorScheme.primary,
emoteOnly: const Color(0xFFFFCC80),
followersOnly: const Color(0xFFE57373),
slowMode: const Color(0xFF64B5F6),
subOnly: const Color(0xFF81C784),
uniqueMode: const Color(0xFFBA68C8),
overlayOnSurface: Colors.white,
);
@ -124,12 +124,17 @@ class FrostyThemes {
return ThemeData(
extensions: [
colorScheme.brightness == Brightness.dark
? FrostyColors.dark
: FrostyColors.light,
? FrostyColors.dark(colorScheme: colorScheme)
: FrostyColors.light(colorScheme: colorScheme),
],
colorScheme: colorScheme,
fontFamily: 'Inter',
splashFactory: Platform.isIOS ? NoSplash.splashFactory : null,
textSelectionTheme: TextSelectionThemeData(
cursorColor: colorScheme.primary,
selectionColor: colorScheme.primary.withValues(alpha: 0.3),
selectionHandleColor: colorScheme.primary,
),
scaffoldBackgroundColor: backgroundColor,
bottomSheetTheme: BottomSheetThemeData(
showDragHandle: true,
@ -139,7 +144,7 @@ class FrostyThemes {
menuTheme: MenuThemeData(
style: MenuStyle(
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(vertical: 4),
EdgeInsets.symmetric(vertical: 2),
),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(
@ -152,6 +157,9 @@ class FrostyThemes {
menuButtonTheme: MenuButtonThemeData(
style: MenuItemButton.styleFrom(
visualDensity: VisualDensity.compact,
minimumSize: const Size(0, 40),
padding: const EdgeInsets.symmetric(horizontal: 12),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
dialogTheme: DialogThemeData(
@ -196,6 +204,23 @@ class FrostyThemes {
),
),
),
listTileTheme: ListTileThemeData(
titleTextStyle: TextStyle(
color: colorScheme.onSurface,
fontSize: 15,
fontWeight: FontWeight.w500,
letterSpacing: -0.011,
height: 1.5,
),
subtitleTextStyle: TextStyle(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
fontSize: 13,
height: 1.4,
),
leadingAndTrailingTextStyle: TextStyle(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
),
navigationBarTheme: NavigationBarThemeData(
elevation: 0,
backgroundColor: backgroundColor,

View file

@ -3,6 +3,7 @@ import 'package:frosty/apis/bttv_api.dart';
import 'package:frosty/apis/ffz_api.dart';
import 'package:frosty/apis/seventv_api.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/stores/global_assets_store.dart';
@ -13,17 +14,17 @@ import 'package:provider/provider.dart';
class OrientationUtils {
/// Gets the current orientation from the given context
static Orientation getCurrentOrientation(BuildContext context) {
return MediaQuery.of(context).orientation;
return MediaQuery.orientationOf(context);
}
/// Returns true if the current orientation is portrait
static bool isPortrait(BuildContext context) {
return MediaQuery.of(context).orientation == Orientation.portrait;
return MediaQuery.orientationOf(context) == Orientation.portrait;
}
/// Returns true if the current orientation is landscape
static bool isLandscape(BuildContext context) {
return MediaQuery.of(context).orientation == Orientation.landscape;
return MediaQuery.orientationOf(context) == Orientation.landscape;
}
}
@ -55,22 +56,25 @@ extension ContextExtensions on BuildContext {
// ===== SCREEN & LAYOUT =====
/// Gets the screen width
double get screenWidth => MediaQuery.of(this).size.width;
double get screenWidth => MediaQuery.sizeOf(this).width;
/// Gets the screen height
double get screenHeight => MediaQuery.of(this).size.height;
double get screenHeight => MediaQuery.sizeOf(this).height;
/// Gets the safe area padding top (commonly used)
double get safePaddingTop => MediaQuery.of(this).padding.top;
double get safePaddingTop => MediaQuery.paddingOf(this).top;
/// Gets the safe area padding bottom (commonly used)
double get safePaddingBottom => MediaQuery.of(this).padding.bottom;
double get safePaddingBottom => MediaQuery.paddingOf(this).bottom;
// ===== PROVIDER ACCESS =====
/// Gets TwitchApi from provider
TwitchApi get twitchApi => read<TwitchApi>();
/// Gets TwitchGqlApi from provider
TwitchGqlApi get twitchGqlApi => read<TwitchGqlApi>();
/// Gets AuthStore from provider
AuthStore get authStore => read<AuthStore>();

View file

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

View file

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

View file

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

View file

@ -7,7 +7,11 @@ class ProfilePicture extends StatefulWidget {
final String userLogin;
final double radius;
const ProfilePicture({super.key, required this.userLogin, this.radius = 20});
const ProfilePicture({
super.key,
required this.userLogin,
this.radius = 20,
});
@override
State<ProfilePicture> createState() => _ProfilePictureState();

View file

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

View file

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