Compare commits

...
Sign in to create a new pull request.

28 commits

Author SHA1 Message Date
Tommy Chow
e73d9b255f add VOD browsing with synchronized chat replay
Implements VOD (past broadcast) browsing and chat replay that reuses
the existing live chat rendering pipeline in read-only mode.

Models & API:
- video.dart: Helix video model with duration parsing (3h26m15s → Duration)
- gql_chat_comment.dart: GQL VOD chat comment models (manual parsing)
- IRCMessage.fromGqlComment(): adapter mapping GQL comments → IRC format
  so the existing chat rendering pipeline (generateSpan, badges, emotes)
  works unchanged for VOD chat
- TwitchApi.getVideos(): Helix endpoint for user VOD list with pagination
- TwitchGqlApi.getVideoComments(): GQL persisted query using mobile
  client ID (bypasses Client-Integrity) for VOD chat comments

Architecture:
- ChatRenderContext interface: minimal abstraction over ChatStore so
  ChatMessage widget works with both live ChatStore and VodChatStore
- ChatStore now implements ChatRenderContext (one-line change)
- ChatMessage accepts ChatRenderContext instead of ChatStore, with
  type guards for live-only actions (reply, paste, name tap navigation)

VOD stores:
- VodChatStore: MobX store implementing ChatRenderContext with identical
  message batching (200ms timer, 5000 limit, 100 render limit, 20% trim),
  scroll/autoscroll, playback position tracking, seek detection
  (delta < -5 or > 10 triggers clear + re-fetch), comment buffer with
  pre-fetching when < 50 comments remain
- VodListStore: standard paginated list store for VOD browsing

VOD UI:
- VodPlayer: matches live channel layout exactly (no AppBar, SafeArea,
  16:9 WebView + expanded chat) with JS position polling every 1s +
  seeked event listener via VodPosition JS channel
- VodList: paginated VOD list with scroll-based loading
- VodCard: thumbnail card with duration overlay and relative dates
- Entry point: "Past broadcasts" in chat details drawer

Tests (21 new):
- video_test.dart: VideoTwitch/VideosTwitch parsing, duration parsing
- gql_chat_comment_test.dart: comment parsing, response error handling,
  IRCMessage.fromGqlComment adapter (tags, localEmotes, deleted user)
- twitch_gql_api_test.dart: getVideoComments mock tests
- twitch_api_test.dart: getVideos mock tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:47:14 -07:00
Tommy Chow
6695c13455 clean up native video player implementation
Extract shared stream quality key constant, add null guard to
playback token parsing, fix handleRefresh race condition and
concurrency, and replace Container with SizedBox placeholder.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:45:15 -07:00
Tommy Chow
d22c980454 fix native player freezing indefinitely on buffer stall
Handle buffering/error activity states that were silently ignored,
causing a frozen frame with no loading indicator. Add two-stage stall
recovery (seek to live edge after 8s, full HLS refresh after 16s).
Stop forcing paused state on transient API failures and restore
playback on app resume unless the user explicitly paused.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:30:36 -07:00
Tommy Chow
3dbdd3501a fix inconsistent chat styles and section header typography
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:34:14 -07:00
Tommy Chow
f9141e3bae fix vertical alignment of web session icon in profile card
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:29:05 -07:00
Tommy Chow
ad718e1bb2 refine list tile and section header typography
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:04:42 -07:00
Tommy Chow
7839dc9135 mute unfocused tab icons on home page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 23:50:32 -07:00
Tommy Chow
3b0e7f9c08 use FrostyDialog for web session info dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 23:37:32 -07:00
Tommy Chow
0a52ca3724 move web session indicator next to username with info icon
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 23:20:29 -07:00
Tommy Chow
9c1be7e121 clean up settings: move keep-awake to chat, improve labels and section headers
- move keepScreenAwake from video to chat settings (store + UI)
- mute section headers (lighter weight, smaller, reduced opacity)
- add breathing room between app bar and first setting
- improve labels across general, video, and chat settings
- reorder store fields to logical grouping with organized reset

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 23:13:44 -07:00
Tommy Chow
8b8b190316 fix video loading spinner not covering full player area
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
9529438a3f add disposed guards to error path in _initPlayer
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
d01ee26fca revert iOS latency measurement to wall-clock approach
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
e7c726daeb guard async callbacks against post-dispose use
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
144c5e3f1f fix stream info mutations outside MobX action context
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
cb79dfd233 update fork with auto quality fix and LiveConfiguration on quality switch
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
97d371c0e1 update fork with media3 1.9.2
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
adb166ff92 hide iOS player placeholder logo via black background
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
a69eb5ddfa update fork with tuned live playback and reverted iOS seek
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
649710c758 show loading spinner instead of flashing offline/play button on video load
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
8d5ccb699e enable native video player on Android
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
d048875490 add web session indicator to profile card, clean up cookie extraction
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
f27de6dc3d remove auto seek-to-live-edge to fix random pausing
The seekToLiveEdge call was firing too aggressively — on initial load
latency reports inflated values before the player stabilizes, and
each seek interrupts playback. The refresh button handles manual
recovery if the viewer falls behind.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
b2039e1cd8 update fork dependency to fix empty switch case build error
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
6e5da0bae0 optimize native player with immediate latency, auto seek-to-live-edge, and app resume recovery
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
418c51ce6d handle offline channels gracefully in native video player
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
1779520db8 use forked plugin for seamless quality switching, add latency tracking and cookie auth
Switch to forked better_native_video_player that uses preferredPeakBitRate
instead of replaceCurrentItem for seamless quality transitions without
black flash. Fork also adds configureForLivePlayback and getLatencyToLive
APIs for low-latency tuning and HLS program date-time based latency.

Add latency polling (10s interval) with auto-sync chat delay support.
Only update syncedChatDelay on >2s drift to avoid restarting countdown.

Add cookie extraction from WKWebView login session for ad-free GQL
playback. The auth-token cookie works with the web Client-ID per
Streamlink's approach. Extract at login, persist to secure storage,
and attempt fresh extraction on init for existing logged-in users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
Tommy Chow
1020b37e2f add experimental native video player with iOS auto-PiP
Adds an opt-in native HLS video player (iOS-only, off by default) that
uses AVPlayerViewController for true automatic picture-in-picture on
swipe-home, replacing the WebView-based player when enabled.

Key changes:
- GQL API client for fetching playback access tokens and building HLS URLs
- NativeVideoStore (MobX) managing player lifecycle, quality selection, PiP
- VideoPlayerInterface abstraction so both stores drive the same overlay
- Settings toggle gated on iOS with "Native player (experimental)" label
- Auth token fallback: tries authenticated GQL for ad-free, falls back to
  anonymous if rejected (app OAuth token != web Client-ID token)
- Scoped UnauthorizedInterceptor to Helix/OAuth URLs only
- iOS Info.plist background audio mode for PiP audio continuity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:34:05 -07:00
59 changed files with 4527 additions and 826 deletions

View file

@ -0,0 +1,32 @@
package com.tommychow.frosty
import android.webkit.CookieManager
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class CookieExtractorPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private lateinit var channel: MethodChannel
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(binding.binaryMessenger, "frosty/cookie_extractor")
channel.setMethodCallHandler(this)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
if (call.method == "extractTwitchAuthToken") {
val cookies = CookieManager.getInstance().getCookie("https://twitch.tv")
val authToken = cookies?.split(";")
?.map { it.trim() }
?.firstOrNull { it.startsWith("auth-token=") }
?.substringAfter("auth-token=")
result.success(authToken)
} else {
result.notImplemented()
}
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
}

View file

@ -1,6 +1,11 @@
package com.tommychow.frosty package com.tommychow.frosty
import cl.puntito.simple_pip_mode.PipCallbackHelperActivityWrapper import cl.puntito.simple_pip_mode.PipCallbackHelperActivityWrapper
import io.flutter.embedding.engine.FlutterEngine
class MainActivity: PipCallbackHelperActivityWrapper() { class MainActivity: PipCallbackHelperActivityWrapper() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(CookieExtractorPlugin())
}
} }

View file

@ -3,6 +3,8 @@ PODS:
- Flutter - Flutter
- app_links (7.0.0): - app_links (7.0.0):
- Flutter - Flutter
- better_native_video_player (0.4.11):
- Flutter
- device_info_plus (0.0.1): - device_info_plus (0.0.1):
- Flutter - Flutter
- Firebase/CoreOnly (12.9.0): - Firebase/CoreOnly (12.9.0):
@ -192,6 +194,7 @@ PODS:
DEPENDENCIES: DEPENDENCIES:
- advanced_in_app_review (from `.symlinks/plugins/advanced_in_app_review/ios`) - advanced_in_app_review (from `.symlinks/plugins/advanced_in_app_review/ios`)
- app_links (from `.symlinks/plugins/app_links/ios`) - app_links (from `.symlinks/plugins/app_links/ios`)
- better_native_video_player (from `.symlinks/plugins/better_native_video_player/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`)
@ -237,6 +240,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/advanced_in_app_review/ios" :path: ".symlinks/plugins/advanced_in_app_review/ios"
app_links: app_links:
:path: ".symlinks/plugins/app_links/ios" :path: ".symlinks/plugins/app_links/ios"
better_native_video_player:
:path: ".symlinks/plugins/better_native_video_player/ios"
device_info_plus: device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios" :path: ".symlinks/plugins/device_info_plus/ios"
firebase_analytics: firebase_analytics:
@ -273,6 +278,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
advanced_in_app_review: fe8fd50b8670cb3dbe67e4c26ca643f7aea8dbb8 advanced_in_app_review: fe8fd50b8670cb3dbe67e4c26ca643f7aea8dbb8
app_links: 6d01271b3907b0ee7325c5297c75d697c4226c4d app_links: 6d01271b3907b0ee7325c5297c75d697c4226c4d
better_native_video_player: 88a6b9bf169428352c05b6ebec085c5263603282
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
Firebase: 065f2bb395062046623036d8e6dc857bc2521d56 Firebase: 065f2bb395062046623036d8e6dc857bc2521d56
firebase_analytics: b5c909bdc321fab0f9ddf8712f5cef79057e09fa firebase_analytics: b5c909bdc321fab0f9ddf8712f5cef79057e09fa

View file

@ -11,6 +11,7 @@
3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E751BC99755AAC391DC0B29F /* Pods_Runner.framework */; }; 3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E751BC99755AAC391DC0B29F /* Pods_Runner.framework */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
F1C00CE01A2B3C4D00E5F6A8 /* CookieExtractorPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
@ -38,6 +39,7 @@
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieExtractorPlugin.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
95D7E205EE04D01F6996ECFC /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; }; 95D7E205EE04D01F6996ECFC /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
@ -106,6 +108,7 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */, D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */,
); );
@ -304,6 +307,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
F1C00CE01A2B3C4D00E5F6A8 /* CookieExtractorPlugin.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;

View file

@ -8,6 +8,7 @@ import UIKit
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
CookieExtractorPlugin.register(with: self.registrar(forPlugin: "CookieExtractorPlugin")!)
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
} }

View file

@ -0,0 +1,26 @@
import Flutter
import WebKit
class CookieExtractorPlugin: NSObject, FlutterPlugin {
static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "frosty/cookie_extractor",
binaryMessenger: registrar.messenger()
)
let instance = CookieExtractorPlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
}
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
if call.method == "extractTwitchAuthToken" {
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
let authCookie = cookies.first {
$0.name == "auth-token" && $0.domain.contains("twitch.tv")
}
result(authCookie?.value)
}
} else {
result(FlutterMethodNotImplemented)
}
}
}

View file

@ -54,5 +54,9 @@
<true/> <true/>
<key>UIStatusBarHidden</key> <key>UIStatusBarHidden</key>
<false/> <false/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
</dict> </dict>
</plist> </plist>

View file

@ -10,6 +10,7 @@ import 'package:frosty/models/followed_channel.dart';
import 'package:frosty/models/shared_chat_session.dart'; import 'package:frosty/models/shared_chat_session.dart';
import 'package:frosty/models/stream.dart'; import 'package:frosty/models/stream.dart';
import 'package:frosty/models/user.dart'; import 'package:frosty/models/user.dart';
import 'package:frosty/models/video.dart';
/// The Twitch service for making API calls. /// The Twitch service for making API calls.
class TwitchApi extends BaseApiClient { class TwitchApi extends BaseApiClient {
@ -205,6 +206,23 @@ class TwitchApi extends BaseApiClient {
return FollowedChannels.fromJson(data); return FollowedChannels.fromJson(data);
} }
/// Returns a [VideosTwitch] object containing past broadcasts for the given [userId].
Future<VideosTwitch> getVideos({
required String userId,
String? cursor,
}) async {
final queryParams = <String, String>{
'user_id': userId,
'first': '20',
'type': 'archive',
};
if (cursor != null) queryParams['after'] = cursor;
final data = await get<JsonMap>('/videos', queryParameters: queryParams);
return VideosTwitch.fromJson(data);
}
/// Returns a [StreamsTwitch] object that contains the list of streams under the given game/category ID. /// Returns a [StreamsTwitch] object that contains the list of streams under the given game/category ID.
Future<StreamsTwitch> getStreamsUnderCategory({ Future<StreamsTwitch> getStreamsUnderCategory({
required String gameId, required String gameId,

View file

@ -0,0 +1,113 @@
import 'dart:math';
import 'package:dio/dio.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/models/gql_chat_comment.dart';
import 'package:frosty/models/playback_access_token.dart';
/// Twitch GQL API client for playback access tokens and VOD chat.
///
/// Uses the Twitch web client ID for playback tokens and the mobile
/// client ID for VOD chat (bypasses Client-Integrity checks).
class TwitchGqlApi extends BaseApiClient {
TwitchGqlApi(Dio dio) : super(dio, 'https://gql.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.
Future<PlaybackAccessToken> getPlaybackAccessToken({
required String login,
String? authToken,
}) async {
final headers = <String, String>{
'Client-ID': twitchGqlClientId,
if (authToken != null) 'Authorization': 'OAuth $authToken',
};
final body = {
'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': {
'isLive': true,
'login': login,
'isVod': false,
'vodID': '',
'playerType': 'site',
},
};
final response = await post<JsonMap>(
'/gql',
data: body,
headers: headers,
);
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);
return 'https://usher.ttvnw.net/api/channel/hls/$login.m3u8'
'?sig=${token.signature}'
'&token=$encodedToken'
'&allow_source=true'
'&allow_audio_only=true'
'&fast_bread=true'
'&supported_codecs=h264'
'&platform=web'
'&playlist_include_framerate=true'
'&p=$random';
}
/// Fetches VOD chat comments for the given [videoId].
///
/// Pass [contentOffsetSeconds] for initial fetch at a position, or
/// [cursor] for pagination from a previous response.
Future<GqlChatResponse> getVideoComments({
required String videoId,
int? contentOffsetSeconds,
String? cursor,
}) async {
final headers = <String, String>{
'Client-ID': twitchGqlMobileClientId,
};
final variables = <String, dynamic>{
'videoID': videoId,
};
if (cursor != null) {
variables['cursor'] = cursor;
} else {
variables['contentOffsetSeconds'] = contentOffsetSeconds ?? 0;
}
final body = {
'operationName': 'VideoCommentsByOffsetOrCursor',
'variables': variables,
'extensions': {
'persistedQuery': {
'version': 1,
'sha256Hash':
'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a',
},
},
};
final response = await post<JsonMap>(
'/gql',
data: body,
headers: headers,
);
return GqlChatResponse.fromJson(response);
}
}

View file

@ -14,8 +14,9 @@ class UnauthorizedInterceptor extends Interceptor {
@override @override
void onError(DioException err, ErrorInterceptorHandler handler) { void onError(DioException err, ErrorInterceptorHandler handler) {
// Check if this is a 401 Unauthorized error // Check if this is a 401 Unauthorized error on a Twitch Helix/OAuth request
if (err.response?.statusCode == 401) { if (err.response?.statusCode == 401 &&
_isTwitchHelixOrOAuth(err.requestOptions.uri)) {
// For token validation requests, let the error propagate so validateToken can handle it // For token validation requests, let the error propagate so validateToken can handle it
if (err.requestOptions.uri.path.endsWith('/validate')) { if (err.requestOptions.uri.path.endsWith('/validate')) {
handler.next(err); handler.next(err);
@ -89,4 +90,10 @@ class UnauthorizedInterceptor extends Interceptor {
} }
static bool _isDialogShowing = false; 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,15 @@ import 'package:flutter/material.dart';
/// Twitch API client ID. /// Twitch API client ID.
const clientId = String.fromEnvironment('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 last-selected stream quality.
const kLastStreamQualityKey = 'last_stream_quality';
/// Twitch GQL mobile client ID (bypasses Client-Integrity checks, used for VOD chat).
const twitchGqlMobileClientId = 'kd1unb4b3q4t58fwlpcbzcbnm76a8fp';
/// Twitch API client secret. /// Twitch API client secret.
const secret = String.fromEnvironment('SECRET'); const secret = String.fromEnvironment('SECRET');

View file

@ -17,6 +17,7 @@ import 'package:frosty/apis/ffz_api.dart';
import 'package:frosty/apis/seventv_api.dart'; import 'package:frosty/apis/seventv_api.dart';
import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/apis/twitch_auth_interceptor.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/apis/unauthorized_interceptor.dart';
import 'package:frosty/cache_manager.dart'; import 'package:frosty/cache_manager.dart';
import 'package:frosty/firebase_options.dart'; import 'package:frosty/firebase_options.dart';
@ -104,6 +105,7 @@ void main() async {
final bttvApiService = BTTVApi(dioClient); final bttvApiService = BTTVApi(dioClient);
final ffzApiService = FFZApi(dioClient); final ffzApiService = FFZApi(dioClient);
final sevenTVApiService = SevenTVApi(dioClient); final sevenTVApiService = SevenTVApi(dioClient);
final twitchGqlApiService = TwitchGqlApi(dioClient);
// Create global assets store (shared cache for global emotes/badges) // Create global assets store (shared cache for global emotes/badges)
final globalAssetsStore = GlobalAssetsStore( final globalAssetsStore = GlobalAssetsStore(
@ -137,6 +139,7 @@ void main() async {
Provider<BTTVApi>.value(value: bttvApiService), Provider<BTTVApi>.value(value: bttvApiService),
Provider<FFZApi>.value(value: ffzApiService), Provider<FFZApi>.value(value: ffzApiService),
Provider<SevenTVApi>.value(value: sevenTVApiService), Provider<SevenTVApi>.value(value: sevenTVApiService),
Provider<TwitchGqlApi>.value(value: twitchGqlApiService),
Provider<GlobalAssetsStore>.value(value: globalAssetsStore), Provider<GlobalAssetsStore>.value(value: globalAssetsStore),
], ],
child: MyApp(firstRun: firstRun), child: MyApp(firstRun: firstRun),

View file

@ -0,0 +1,137 @@
// Models for Twitch GQL VOD chat comments.
//
// Manually parsed (no codegen) because the GQL response uses camelCase
// and deeply nested edges/node patterns.
class GqlChatEmote {
final String emoteId;
const GqlChatEmote({required this.emoteId});
factory GqlChatEmote.fromJson(Map<String, dynamic> json) {
return GqlChatEmote(emoteId: json['emoteID'] as String);
}
}
class GqlChatFragment {
final String? text;
final GqlChatEmote? emote;
const GqlChatFragment({this.text, this.emote});
factory GqlChatFragment.fromJson(Map<String, dynamic> json) {
return GqlChatFragment(
text: json['text'] as String?,
emote: json['emote'] != null
? GqlChatEmote.fromJson(json['emote'] as Map<String, dynamic>)
: null,
);
}
}
class GqlChatBadge {
final String setId;
final String version;
const GqlChatBadge({required this.setId, required this.version});
factory GqlChatBadge.fromJson(Map<String, dynamic> json) {
return GqlChatBadge(
setId: json['setID'] as String,
version: json['version'] as String,
);
}
}
class GqlChatComment {
final String id;
final int contentOffsetSeconds;
final String? commenterLogin;
final String? commenterDisplayName;
final String? commenterId;
final String? userColor;
final List<GqlChatBadge> userBadges;
final List<GqlChatFragment> messageFragments;
const GqlChatComment({
required this.id,
required this.contentOffsetSeconds,
this.commenterLogin,
this.commenterDisplayName,
this.commenterId,
this.userColor,
required this.userBadges,
required this.messageFragments,
});
factory GqlChatComment.fromGqlNode(Map<String, dynamic> node) {
final commenter = node['commenter'] as Map<String, dynamic>?;
final message = node['message'] as Map<String, dynamic>;
return GqlChatComment(
id: node['id'] as String,
contentOffsetSeconds: node['contentOffsetSeconds'] as int,
commenterLogin: commenter?['login'] as String?,
commenterDisplayName: commenter?['displayName'] as String?,
commenterId: commenter?['id'] as String?,
userColor: message['userColor'] as String?,
userBadges: (message['userBadges'] as List)
.map((b) => GqlChatBadge.fromJson(b as Map<String, dynamic>))
.toList(),
messageFragments: (message['fragments'] as List)
.map((f) => GqlChatFragment.fromJson(f as Map<String, dynamic>))
.toList(),
);
}
}
class GqlChatResponse {
final List<GqlChatComment> comments;
final bool hasNextPage;
final String? cursor;
const GqlChatResponse({
required this.comments,
required this.hasNextPage,
this.cursor,
});
factory GqlChatResponse.fromJson(Map<String, dynamic> json) {
// GQL returns 200 even on errors — check for errors array.
if (json['errors'] != null) {
final errors = json['errors'] as List;
final message = errors.isNotEmpty
? (errors.first as Map<String, dynamic>)['message'] as String?
: 'Unknown GQL error';
throw Exception('GQL error: $message');
}
final video = json['data']?['video'] as Map<String, dynamic>?;
if (video == null) {
return const GqlChatResponse(
comments: [],
hasNextPage: false,
);
}
final commentsData = video['comments'] as Map<String, dynamic>;
final edges = commentsData['edges'] as List;
final pageInfo = commentsData['pageInfo'] as Map<String, dynamic>;
final comments = edges
.map((edge) => GqlChatComment.fromGqlNode(
(edge as Map<String, dynamic>)['node'] as Map<String, dynamic>,
))
.toList();
final cursor = edges.isNotEmpty
? (edges.last as Map<String, dynamic>)['cursor'] as String?
: null;
return GqlChatResponse(
comments: comments,
hasNextPage: pageInfo['hasNextPage'] as bool,
cursor: cursor,
);
}
}

View file

@ -8,6 +8,7 @@ import 'package:frosty/cache_manager.dart';
import 'package:frosty/constants.dart'; import 'package:frosty/constants.dart';
import 'package:frosty/models/badges.dart'; import 'package:frosty/models/badges.dart';
import 'package:frosty/models/emotes.dart'; import 'package:frosty/models/emotes.dart';
import 'package:frosty/models/gql_chat_comment.dart';
import 'package:frosty/models/user.dart'; import 'package:frosty/models/user.dart';
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart';
@ -176,7 +177,9 @@ class IRCMessage {
size: badgeSize, size: badgeSize,
color: color:
Theme.of(context).iconTheme.color?.withValues(alpha: 0.5) ?? Theme.of(context).iconTheme.color?.withValues(alpha: 0.5) ??
Colors.grey.withValues(alpha: 0.5), Theme.of(context).colorScheme.onSurfaceVariant.withValues(
alpha: 0.5,
),
), ),
), ),
), ),
@ -1153,6 +1156,56 @@ class IRCMessage {
); );
} }
/// Creates an [IRCMessage] from a GQL VOD chat comment.
///
/// Maps GQL fields to IRC tags so [generateSpan] works identically
/// for both live and VOD chat messages.
factory IRCMessage.fromGqlComment(GqlChatComment comment) {
final tags = <String, String>{
'display-name': comment.commenterDisplayName ?? 'Deleted User',
'user-id': comment.commenterId ?? '',
'id': comment.id,
'tmi-sent-ts': '${comment.contentOffsetSeconds * 1000}',
if (comment.userColor != null) 'color': comment.userColor!,
if (comment.userBadges.isNotEmpty)
'badges': comment.userBadges
.map((b) => '${b.setId}/${b.version}')
.join(','),
};
// Reconstruct message text from fragments.
final messageText = comment.messageFragments
.map((f) => f.text ?? '')
.join();
// Build local emotes map from fragments that have Twitch emotes.
// generateSpan() will match these by name in the split words.
final localEmotes = <String, Emote>{};
for (final fragment in comment.messageFragments) {
if (fragment.emote != null && fragment.text != null) {
localEmotes[fragment.text!] = Emote(
name: fragment.text!,
zeroWidth: false,
url:
'$_twitchEmoteBaseUrl/${fragment.emote!.emoteId}$_emoteModeSuffix',
type: EmoteType.twitchSub,
);
}
}
return IRCMessage(
raw: '',
command: Command.privateMessage,
tags: tags,
user: comment.commenterLogin,
localEmotes: localEmotes.isNotEmpty ? localEmotes : null,
message: messageText,
split: messageText.split(' ').where((w) => w.isNotEmpty).toList(),
action: false,
mention: false,
);
}
factory IRCMessage.createNotice({ factory IRCMessage.createNotice({
required String message, required String message,
VoidCallback? actionCallback, VoidCallback? actionCallback,

View file

@ -0,0 +1,24 @@
class PlaybackAccessToken {
final String value;
final String signature;
const PlaybackAccessToken({
required this.value,
required this.signature,
});
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',
);
}
return PlaybackAccessToken(
value: data['value'] as String,
signature: data['signature'] as String,
);
}
}

60
lib/models/video.dart Normal file
View file

@ -0,0 +1,60 @@
import 'package:json_annotation/json_annotation.dart';
part 'video.g.dart';
/// Object for Twitch VODs (past broadcasts).
@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
class VideoTwitch {
final String id;
final String? streamId;
final String userId;
final String userLogin;
final String userName;
final String title;
final String createdAt;
final String publishedAt;
final String duration;
final String thumbnailUrl;
final int viewCount;
const VideoTwitch(
this.id,
this.streamId,
this.userId,
this.userLogin,
this.userName,
this.title,
this.createdAt,
this.publishedAt,
this.duration,
this.thumbnailUrl,
this.viewCount,
);
factory VideoTwitch.fromJson(Map<String, dynamic> json) =>
_$VideoTwitchFromJson(json);
}
@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
class VideosTwitch {
final List<VideoTwitch> data;
final Map<String, String> pagination;
const VideosTwitch(this.data, this.pagination);
factory VideosTwitch.fromJson(Map<String, dynamic> json) =>
_$VideosTwitchFromJson(json);
}
/// Parses a Twitch duration string like "3h26m15s" into a [Duration].
Duration parseTwitchDuration(String duration) {
final regex = RegExp(r'(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?');
final match = regex.firstMatch(duration);
if (match == null) return Duration.zero;
final hours = int.tryParse(match.group(1) ?? '') ?? 0;
final minutes = int.tryParse(match.group(2) ?? '') ?? 0;
final seconds = int.tryParse(match.group(3) ?? '') ?? 0;
return Duration(hours: hours, minutes: minutes, seconds: seconds);
}

28
lib/models/video.g.dart Normal file
View file

@ -0,0 +1,28 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'video.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
VideoTwitch _$VideoTwitchFromJson(Map<String, dynamic> json) => VideoTwitch(
json['id'] as String,
json['stream_id'] as String?,
json['user_id'] as String,
json['user_login'] as String,
json['user_name'] as String,
json['title'] as String,
json['created_at'] as String,
json['published_at'] as String,
json['duration'] as String,
json['thumbnail_url'] as String,
(json['view_count'] as num).toInt(),
);
VideosTwitch _$VideosTwitchFromJson(Map<String, dynamic> json) => VideosTwitch(
(json['data'] as List<dynamic>)
.map((e) => VideoTwitch.fromJson(e as Map<String, dynamic>))
.toList(),
Map<String, String>.from(json['pagination'] as Map),
);

View file

@ -6,9 +6,12 @@ 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_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_tabs_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/chat/widgets/chat_tabs.dart';
import 'package:frosty/screens/channel/video/native_video.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/stream_info_bar.dart';
import 'package:frosty/screens/channel/video/video.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_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/channel/video/video_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/theme.dart'; import 'package:frosty/theme.dart';
@ -75,13 +78,23 @@ class _VideoChatState extends State<VideoChat>
primaryDisplayName: widget.userName, primaryDisplayName: widget.userName,
); );
late final VideoStore _videoStore = VideoStore( late final VideoPlayerInterface _videoStore =
userLogin: widget.userLogin, context.settingsStore.useNativePlayer
userId: widget.userId, ? NativeVideoStore(
twitchApi: context.twitchApi, userLogin: widget.userLogin,
authStore: context.authStore, userId: widget.userId,
settingsStore: context.settingsStore, 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 @override
void initState() { void initState() {
@ -297,7 +310,12 @@ class _VideoChatState extends State<VideoChat>
final player = GestureDetector( final player = GestureDetector(
onLongPress: _videoStore.handleToggleOverlay, 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( final overlay = GestureDetector(
@ -318,6 +336,10 @@ class _VideoChatState extends State<VideoChat>
}, },
child: Observer( child: Observer(
builder: (_) { builder: (_) {
if (_videoStore.loading) {
return const SizedBox.shrink();
}
final videoOverlay = VideoOverlay( final videoOverlay = VideoOverlay(
videoStore: _videoStore, videoStore: _videoStore,
chatStore: _chatStore, chatStore: _chatStore,
@ -349,6 +371,28 @@ class _VideoChatState extends State<VideoChat>
return Stack( return Stack(
children: [ children: [
player, player,
Positioned.fill(
child: AnimatedOpacity(
opacity: _videoStore.loading ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
child: const IgnorePointer(
child: ColoredBox(
color: Colors.black,
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: Colors.white38,
strokeWidth: 2,
),
),
),
),
),
),
),
if (_videoStore.settingsStore.showOverlay) overlay, if (_videoStore.settingsStore.showOverlay) overlay,
], ],
); );
@ -683,7 +727,7 @@ class _VideoChatState extends State<VideoChat>
if (settingsStore.showVideo) ...[ if (settingsStore.showVideo) ...[
AspectRatio( AspectRatio(
aspectRatio: 16 / 9, aspectRatio: 16 / 9,
child: Container(), // Placeholder for video space child: SizedBox(),
), ),
], ],
Expanded(child: chat), Expanded(child: chat),

View file

@ -0,0 +1,18 @@
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:mobx/mobx.dart';
/// Minimal interface for rendering chat messages.
///
/// Implemented by both [ChatStore] (live chat) and [VodChatStore] (VOD replay)
/// so that [ChatMessage] can render messages from either source.
abstract class ChatRenderContext {
ChatAssetsStore get assetsStore;
AuthStore get auth;
SettingsStore get settings;
String get channelId;
ObservableSet<String> get revealedMessageIds;
void revealMessage(String id);
void updateNotification(String message);
}

View file

@ -10,6 +10,7 @@ import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
import 'package:frosty/screens/channel/chat/details/chat_modes.dart'; import 'package:frosty/screens/channel/chat/details/chat_modes.dart';
import 'package:frosty/screens/channel/chat/details/chat_users_list.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/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/vod/vod_list.dart';
import 'package:frosty/screens/settings/settings.dart'; import 'package:frosty/screens/settings/settings.dart';
import 'package:frosty/utils.dart'; import 'package:frosty/utils.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart'; import 'package:frosty/utils/modal_bottom_sheet.dart';
@ -371,6 +372,20 @@ class _ChatDetailsState extends State<ChatDetails> {
), ),
), ),
), ),
ListTile(
leading: const Icon(Icons.video_library_rounded),
title: const Text('Past broadcasts'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => VodList(
userId: widget.chatStore.channelId,
displayName: widget.chatStore.displayName,
),
),
),
),
ListTile( ListTile(
leading: const Icon(Icons.add_comment_rounded), leading: const Icon(Icons.add_comment_rounded),
title: const Text('Add chat'), title: const Text('Add chat'),

View file

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

View file

@ -9,6 +9,7 @@ import 'package:frosty/models/badges.dart';
import 'package:frosty/models/emotes.dart'; import 'package:frosty/models/emotes.dart';
import 'package:frosty/models/events.dart'; import 'package:frosty/models/events.dart';
import 'package:frosty/models/irc.dart'; import 'package:frosty/models/irc.dart';
import 'package:frosty/screens/channel/chat/chat_render_context.dart';
import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart';
@ -22,7 +23,7 @@ part 'chat_store.g.dart';
/// The store and view-model for chat-related activities. /// The store and view-model for chat-related activities.
class ChatStore = ChatStoreBase with _$ChatStore; class ChatStore = ChatStoreBase with _$ChatStore;
abstract class ChatStoreBase with Store { abstract class ChatStoreBase with Store implements ChatRenderContext {
/// The total maximum amount of messages in chat. /// The total maximum amount of messages in chat.
static const _messageLimit = 5000; static const _messageLimit = 5000;
@ -99,9 +100,11 @@ abstract class ChatStoreBase with Store {
final _messagesToRemove = (_messageLimit * 0.2).toInt(); final _messagesToRemove = (_messageLimit * 0.2).toInt();
/// The provided auth store to determine login status, get the token, and use the headers for requests. /// The provided auth store to determine login status, get the token, and use the headers for requests.
@override
final AuthStore auth; final AuthStore auth;
/// The provided setting store to account for any user-defined behaviors. /// The provided setting store to account for any user-defined behaviors.
@override
final SettingsStore settings; final SettingsStore settings;
/// The focus node for the textfield to allow for showing and hiding the keyboard/focus. /// The focus node for the textfield to allow for showing and hiding the keyboard/focus.
@ -111,6 +114,7 @@ abstract class ChatStoreBase with Store {
final String channelName; final String channelName;
/// The channel's ID for API requests. /// The channel's ID for API requests.
@override
final String channelId; final String channelId;
/// The channel's display name to show on widgets. /// The channel's display name to show on widgets.
@ -153,6 +157,7 @@ abstract class ChatStoreBase with Store {
final ChatDetailsStore chatDetailsStore; final ChatDetailsStore chatDetailsStore;
/// The assets store responsible for badges, emotes, and the emote menu. /// The assets store responsible for badges, emotes, and the emote menu.
@override
final ChatAssetsStore assetsStore; final ChatAssetsStore assetsStore;
/// Requested message to be sent by the user. Will only be sent on receipt of a USERNOTICE command. /// Requested message to be sent by the user. Will only be sent on receipt of a USERNOTICE command.
@ -175,8 +180,10 @@ abstract class ChatStoreBase with Store {
final _pendingSevenTVCallbacks = <_DelayedCallback>[]; final _pendingSevenTVCallbacks = <_DelayedCallback>[];
/// The set of message IDs that have been revealed by the user (for deleted messages). /// The set of message IDs that have been revealed by the user (for deleted messages).
@override
final revealedMessageIds = ObservableSet<String>(); final revealedMessageIds = ObservableSet<String>();
@override
@action @action
void revealMessage(String id) { void revealMessage(String id) {
revealedMessageIds.add(id); revealedMessageIds.add(id);
@ -1269,6 +1276,7 @@ abstract class ChatStoreBase with Store {
} }
/// Cancels the previous notification/timer and creates a new one with the provided [notificationMessage]. /// Cancels the previous notification/timer and creates a new one with the provided [notificationMessage].
@override
@action @action
void updateNotification(String notificationMessage) { void updateNotification(String notificationMessage) {
// Cancel the previous notification to prevent the notification from phasing in and out // Cancel the previous notification to prevent the notification from phasing in and out

View file

@ -6,6 +6,7 @@ import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/constants.dart'; import 'package:frosty/constants.dart';
import 'package:frosty/models/irc.dart'; import 'package:frosty/models/irc.dart';
import 'package:frosty/models/user.dart'; import 'package:frosty/models/user.dart';
import 'package:frosty/screens/channel/chat/chat_render_context.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart';
import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart'; import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart';
@ -15,7 +16,7 @@ import 'package:provider/provider.dart';
class ChatMessage extends StatelessWidget { class ChatMessage extends StatelessWidget {
final IRCMessage ircMessage; final IRCMessage ircMessage;
final ChatStore chatStore; final ChatRenderContext chatStore;
final bool isModal; final bool isModal;
final bool showReplyHeader; final bool showReplyHeader;
final bool isInReplyThread; final bool isInReplyThread;
@ -51,6 +52,9 @@ class ChatMessage extends StatelessWidget {
// Ignore if the message is a recent message in the modal bottom sheet. // Ignore if the message is a recent message in the modal bottom sheet.
if (isModal) return; if (isModal) return;
// User modal requires live ChatStore (recent messages, blocking, etc.).
if (chatStore is! ChatStore) return;
// Ignore if long-pressing own username. // Ignore if long-pressing own username.
if (ircMessage.user == null || if (ircMessage.user == null ||
ircMessage.user == chatStore.auth.user.details?.login) { ircMessage.user == chatStore.auth.user.details?.login) {
@ -61,7 +65,7 @@ class ChatMessage extends StatelessWidget {
isScrollControlled: true, isScrollControlled: true,
context: context, context: context,
builder: (context) => ChatUserModal( builder: (context) => ChatUserModal(
chatStore: chatStore, chatStore: chatStore as ChatStore,
username: ircMessage.user!, username: ircMessage.user!,
userId: ircMessage.tags['user-id']!, userId: ircMessage.tags['user-id']!,
displayName: ircMessage.tags['display-name']!, displayName: ircMessage.tags['display-name']!,
@ -73,6 +77,9 @@ class ChatMessage extends StatelessWidget {
// Ignore if the message is a recent message in the modal bottom sheet. // Ignore if the message is a recent message in the modal bottom sheet.
if (isModal) return; if (isModal) return;
// User modal requires live ChatStore.
if (chatStore is! ChatStore) return;
final twitchApi = context.read<TwitchApi>(); final twitchApi = context.read<TwitchApi>();
twitchApi.getUser(userLogin: nickname).then((user) { twitchApi.getUser(userLogin: nickname).then((user) {
if (context.mounted) { if (context.mounted) {
@ -80,7 +87,7 @@ class ChatMessage extends StatelessWidget {
isScrollControlled: true, isScrollControlled: true,
context: context, context: context,
builder: (context) => ChatUserModal( builder: (context) => ChatUserModal(
chatStore: chatStore, chatStore: chatStore as ChatStore,
username: user.login, username: user.login,
userId: user.id, userId: user.id,
displayName: user.displayName, displayName: user.displayName,
@ -91,7 +98,7 @@ class ChatMessage extends StatelessWidget {
} }
/// The store to use for notifications and emote menu in merged mode. /// The store to use for notifications and emote menu in merged mode.
ChatStore get _effectiveInputStore => inputChatStore ?? chatStore; ChatRenderContext get _effectiveInputStore => inputChatStore ?? chatStore;
Future<void> copyMessage() async { Future<void> copyMessage() async {
await Clipboard.setData(ClipboardData(text: ircMessage.message ?? '')); await Clipboard.setData(ClipboardData(text: ircMessage.message ?? ''));
@ -144,53 +151,58 @@ class ChatMessage extends StatelessWidget {
leading: const Icon(Icons.copy), leading: const Icon(Icons.copy),
title: const Text('Copy message'), title: const Text('Copy message'),
), ),
ListTile( if (chatStore is ChatStore) ...[
onTap: () async { ListTile(
await copyMessage(); onTap: () async {
await copyMessage();
final liveChatStore = chatStore as ChatStore;
final effectiveChatDelay = chatStore.settings.effectiveChatDelay; final effectiveChatDelay =
liveChatStore.settings.effectiveChatDelay;
final hasChatDelay = final hasChatDelay =
chatStore.settings.showVideo && effectiveChatDelay > 0; liveChatStore.settings.showVideo && effectiveChatDelay > 0;
if (hasChatDelay) { if (hasChatDelay) {
_effectiveInputStore.updateNotification( _effectiveInputStore.updateNotification(
'Chatting is disabled due to message delay (${effectiveChatDelay.toInt()}s)', 'Chatting is disabled due to message delay (${effectiveChatDelay.toInt()}s)',
); );
if (context.mounted) {
Navigator.pop(context);
}
return;
}
// In merged mode, switch to the source tab so the paste
// targets the correct channel's composer.
onActivateSourceTab?.call();
// Paste the copied message into the text controller
liveChatStore.textController.text = ircMessage.message ?? '';
liveChatStore.safeRequestFocus();
if (context.mounted) { if (context.mounted) {
Navigator.pop(context); Navigator.pop(context);
} }
return; },
} leading: const Icon(Icons.content_paste),
title: const Text('Copy message and paste'),
),
ListTile(
onTap: () {
final liveChatStore = chatStore as ChatStore;
// In merged mode, switch to the source tab so the reply
// targets the correct channel (reply-parent-msg-id is
// channel-scoped).
onActivateSourceTab?.call();
// In merged mode, switch to the source tab so the paste liveChatStore.replyingToMessage = ircMessage;
// targets the correct channel's composer. liveChatStore.safeRequestFocus();
onActivateSourceTab?.call();
// Paste the copied message into the text controller
chatStore.textController.text = ircMessage.message ?? '';
chatStore.safeRequestFocus();
if (context.mounted) {
Navigator.pop(context); Navigator.pop(context);
} },
}, leading: const Icon(Icons.reply),
leading: const Icon(Icons.content_paste), title: const Text('Reply to message'),
title: const Text('Copy message and paste'), ),
), ],
ListTile(
onTap: () {
// In merged mode, switch to the source tab so the reply
// targets the correct channel (reply-parent-msg-id is
// channel-scoped).
onActivateSourceTab?.call();
chatStore.replyingToMessage = ircMessage;
chatStore.safeRequestFocus();
Navigator.pop(context);
},
leading: const Icon(Icons.reply),
title: const Text('Reply to message'),
),
], ],
), ),
); );
@ -255,14 +267,14 @@ class ChatMessage extends StatelessWidget {
color: messageHeaderTextColor, color: messageHeaderTextColor,
); );
messageHeader = GestureDetector( messageHeader = GestureDetector(
onTap: isModal onTap: isModal || chatStore is! ChatStore
? null ? null
: () => showModalBottomSheetWithProperFocus( : () => showModalBottomSheetWithProperFocus(
context: context, context: context,
builder: (context) { builder: (context) {
return ReplyThread( return ReplyThread(
selectedMessage: ircMessage, selectedMessage: ircMessage,
chatStore: chatStore, chatStore: chatStore as ChatStore,
); );
}, },
), ),
@ -482,10 +494,14 @@ class ChatMessage extends StatelessWidget {
messageHeaderIcon = Icon( messageHeaderIcon = Icon(
Icons.campaign_rounded, Icons.campaign_rounded,
size: messageHeaderIconSize, size: messageHeaderIconSize,
color: messageHeaderTextColor,
); );
messageHeader = const Text( messageHeader = Text(
'Announcement', 'Announcement',
style: TextStyle(fontWeight: messageHeaderFontWeight), style: TextStyle(
fontWeight: messageHeaderFontWeight,
color: messageHeaderTextColor,
),
); );
} }

View file

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

View file

@ -0,0 +1,62 @@
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 (error != null)
Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
error,
style: const TextStyle(color: Colors.white70),
textAlign: TextAlign.center,
),
),
),
if (isOffline)
const Center(
child: Text(
'Offline',
style: TextStyle(
color: Colors.white38,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
},
);
}
}

View file

@ -0,0 +1,535 @@
import 'dart:async';
import 'dart:io';
import 'package:better_native_video_player/better_native_video_player.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/models/channel.dart';
import 'package:frosty/models/playback_access_token.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/channel/video/video_player_interface.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:mobx/mobx.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:simple_pip_mode/simple_pip.dart';
part 'native_video_store.g.dart';
class NativeVideoStore = NativeVideoStoreBase with _$NativeVideoStore;
abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface {
static int _nextId = 0;
final String userLogin;
final String userId;
final TwitchApi twitchApi;
final TwitchGqlApi twitchGqlApi;
final AuthStore authStore;
@override
final SettingsStore settingsStore;
@readonly
NativeVideoPlayerController? _controller;
Timer? _overlayTimer;
Timer? _latencyTimer;
Timer? _stallRecoveryTimer;
var _stallRecoveryAttempt = 0;
DateTime? _lastStreamInfoUpdate;
Future<void>? _streamInfoRequest;
bool _overlayWasVisibleBeforePip = true;
var _userPaused = false;
List<NativeVideoPlayerQuality> _qualityObjects = [];
var _firstTimeSettingQuality = true;
int? _pendingQualityIndex;
var _disposed = false;
final _pip = SimplePip();
ReactionDisposer? _disposeAndroidAutoPipReaction;
StreamSubscription<bool>? _pipSub;
StreamSubscription<List<NativeVideoPlayerQuality>>? _qualitiesSub;
@readonly
var _loading = true;
@readonly
var _paused = true;
@readonly
var _overlayVisible = true;
@readonly
StreamTwitch? _streamInfo;
@readonly
Channel? _offlineChannelInfo;
@readonly
var _availableStreamQualities = <String>[];
@readonly
var _streamQualityIndex = 0;
@override
String get streamQuality =>
_availableStreamQualities.elementAtOrNull(_streamQualityIndex) ?? 'Auto';
@readonly
String? _latency;
@readonly
var _isInPipMode = false;
@readonly
String? _error;
String? _hlsUrl;
NativeVideoStoreBase({
required this.userLogin,
required this.userId,
required this.twitchApi,
required this.twitchGqlApi,
required this.authStore,
required this.settingsStore,
}) {
_controller = NativeVideoPlayerController(
id: _nextId++,
autoPlay: true,
showNativeControls: false,
);
_controller!.addActivityListener(_handleActivityEvent);
_scheduleOverlayHide();
updateStreamInfo();
_initPlayer();
if (Platform.isAndroid) {
_disposeAndroidAutoPipReaction = autorun((_) async {
if (settingsStore.showVideo && await SimplePip.isAutoPipAvailable) {
_pip.setAutoPipMode();
} else {
_pip.setAutoPipMode(autoEnter: false);
}
});
}
}
@action
Future<void> _initPlayer() async {
try {
// Use web cookie token (works with web Client-ID) for ad-free playback.
final authToken = authStore.gqlToken;
late final PlaybackAccessToken token;
try {
token = await twitchGqlApi.getPlaybackAccessToken(
login: userLogin,
authToken: authToken,
);
} catch (_) {
if (authToken != null) {
token = await twitchGqlApi.getPlaybackAccessToken(
login: userLogin,
);
} else {
rethrow;
}
}
if (_disposed) return;
_hlsUrl = twitchGqlApi.buildHlsUrl(login: userLogin, token: token);
await _controller!.initialize();
if (_disposed) return;
_pipSub = _controller!.isPipEnabledStream.listen((isPip) {
runInAction(() {
if (isPip && !_isInPipMode) {
_overlayWasVisibleBeforePip = _overlayVisible;
_isInPipMode = true;
_overlayTimer?.cancel();
_overlayVisible = true;
} else if (!isPip && _isInPipMode) {
_isInPipMode = false;
if (_overlayWasVisibleBeforePip) {
_scheduleOverlayHide();
} else {
_overlayVisible = false;
}
}
});
});
_qualitiesSub = _controller!.qualitiesStream.listen((qualities) {
runInAction(() {
// Filter out the plugin's auto entry (we add our own) and deduplicate.
final seen = <String>{};
final filtered = qualities
.where((q) => !q.isAuto && seen.add(q.label))
.toList();
// Reverse so highest quality comes first.
_qualityObjects = filtered.reversed.toList();
_availableStreamQualities = [
'Auto',
..._qualityObjects.map((q) => q.label),
];
if (_firstTimeSettingQuality && qualities.isNotEmpty) {
_firstTimeSettingQuality = false;
if (settingsStore.defaultToHighestQuality) {
_pendingQualityIndex = 1;
} else {
SharedPreferences.getInstance().then((prefs) {
if (_disposed) return;
final lastQuality = prefs.getString(kLastStreamQualityKey);
if (lastQuality != null) {
final index = _availableStreamQualities.indexOf(lastQuality);
if (index != -1) _pendingQualityIndex = index;
}
});
}
}
});
});
await _controller!.loadUrl(url: _hlsUrl!);
await _controller!.configureForLivePlayback();
_startLatencyPolling();
runInAction(() {
_error = null;
});
} catch (e) {
if (_disposed) return;
// Wait for stream info so we can distinguish "offline" from "broken".
await updateStreamInfo(forceUpdate: true);
if (_disposed) return;
runInAction(() {
_loading = false;
// Only show the error if the stream is actually live — an offline
// channel is expected to fail here and the overlay handles it.
if (_streamInfo != null) {
_error =
'Native player failed to load. Try the standard player in Settings.';
}
});
debugPrint('NativeVideoStore init error: $e');
}
}
void _handleActivityEvent(PlayerActivityEvent event) {
runInAction(() {
switch (event.state) {
case PlayerActivityState.playing:
_loading = false;
_paused = false;
_stallRecoveryTimer?.cancel();
_stallRecoveryAttempt = 0;
if (_pendingQualityIndex != null) {
final index = _pendingQualityIndex!;
_pendingQualityIndex = null;
_setStreamQualityIndex(index);
}
case PlayerActivityState.buffering:
case PlayerActivityState.loading:
_loading = true;
if (!_userPaused) _startStallRecoveryTimer();
case PlayerActivityState.error:
_loading = false;
_stallRecoveryTimer?.cancel();
_error = event.data?['message'] as String? ??
'Playback error. Try refreshing or switch to the standard player in Settings.';
case PlayerActivityState.paused:
case PlayerActivityState.stopped:
case PlayerActivityState.completed:
case PlayerActivityState.idle:
_paused = true;
_stallRecoveryTimer?.cancel();
default:
break;
}
});
}
void _startStallRecoveryTimer() {
_stallRecoveryTimer?.cancel();
_stallRecoveryTimer = Timer(const Duration(seconds: 8), () {
if (_disposed || !_loading || _userPaused) return;
_stallRecoveryAttempt++;
if (_stallRecoveryAttempt <= 1) {
// Light recovery: seek to live edge and resume.
debugPrint('NativeVideoStore: stall detected, seeking to live edge');
_controller?.seekToLiveEdge();
_controller?.play();
_startStallRecoveryTimer();
} else {
// Heavy recovery: new HLS token + full player restart.
debugPrint('NativeVideoStore: stall persists, refreshing player');
handleRefresh();
}
});
}
void _startLatencyPolling() {
_latencyTimer?.cancel();
if (settingsStore.autoSyncChatDelay) {
settingsStore.syncedChatDelay = 0;
}
_pollLatency();
_latencyTimer = Timer.periodic(
const Duration(seconds: 10),
(_) => _pollLatency(),
);
}
Future<void> _pollLatency() async {
final seconds = await _controller?.getLatencyToLive();
if (seconds == null) return;
final rounded = seconds.round();
runInAction(() {
_latency = '${rounded}s';
});
if (!settingsStore.autoSyncChatDelay) return;
// Only update when unset or drifted by >2s to avoid restarting
// the chat countdown on minor fluctuations.
final current = settingsStore.syncedChatDelay;
if (current == 0 || (seconds - current).abs() > 2) {
settingsStore.syncedChatDelay = seconds;
}
}
void _scheduleOverlayHide([Duration delay = const Duration(seconds: 5)]) {
_overlayTimer?.cancel();
if (_isInPipMode) {
_overlayVisible = true;
return;
}
_overlayTimer = Timer(delay, () {
if (_isInPipMode) return;
runInAction(() {
_overlayVisible = false;
});
});
}
@override
@action
void handleVideoTap() {
if (_isInPipMode) {
_overlayVisible = true;
return;
}
_overlayTimer?.cancel();
if (_overlayVisible) {
_overlayVisible = false;
} else {
updateStreamInfo(forceUpdate: true);
_overlayVisible = true;
_scheduleOverlayHide();
}
}
@override
void handlePausePlay() {
if (_controller == null) return;
if (_paused) {
_userPaused = false;
_controller!.play();
} else {
_userPaused = true;
_controller!.pause();
}
}
@override
@action
void handleToggleOverlay() {
if (settingsStore.toggleableOverlay) {
HapticFeedback.mediumImpact();
settingsStore.showOverlay = !settingsStore.showOverlay;
if (settingsStore.showOverlay) {
_overlayVisible = true;
_scheduleOverlayHide(const Duration(seconds: 3));
}
}
}
@override
@action
Future<void> handleRefresh() async {
HapticFeedback.lightImpact();
_loading = true;
_paused = true;
_userPaused = false;
_stallRecoveryAttempt = 0;
_firstTimeSettingQuality = true;
_pendingQualityIndex = null;
_isInPipMode = false;
_availableStreamQualities = [];
_qualityObjects = [];
_error = null;
_latencyTimer?.cancel();
_stallRecoveryTimer?.cancel();
_pipSub?.cancel();
_qualitiesSub?.cancel();
_controller?.removeActivityListener(_handleActivityEvent);
_controller?.dispose();
_controller = NativeVideoPlayerController(
id: _nextId++,
autoPlay: true,
showNativeControls: false,
);
_controller!.addActivityListener(_handleActivityEvent);
_initPlayer();
updateStreamInfo();
}
@override
void requestPictureInPicture() {
if (Platform.isAndroid) {
_pip.enterPipMode(autoEnter: true);
} else {
_controller?.enterPictureInPicture();
}
}
@override
@action
void togglePictureInPicture() {
if (Platform.isIOS && _isInPipMode) {
_controller?.exitPictureInPicture();
} else {
requestPictureInPicture();
}
}
@override
@action
Future<void> updateStreamQualities() async {
// Qualities are populated automatically via qualitiesStream.
}
@override
@action
Future<void> setStreamQuality(String quality) async {
final index = _availableStreamQualities.indexOf(quality);
if (index == -1) return;
await _setStreamQualityIndex(index);
}
@action
Future<void> _setStreamQualityIndex(int index) async {
_streamQualityIndex = index;
if (_controller == null) return;
if (index == 0) {
// 'Auto' — reset to adaptive quality
await _controller!.setQuality(NativeVideoPlayerQuality.auto());
} else {
final qualityIndex = index - 1;
if (qualityIndex < _qualityObjects.length) {
await _controller!.setQuality(_qualityObjects[qualityIndex]);
}
}
}
@override
@action
Future<void> updateStreamInfo({bool forceUpdate = false}) async {
if (_streamInfoRequest != null) {
await _streamInfoRequest;
return;
}
final now = DateTime.now();
if (!forceUpdate && _lastStreamInfoUpdate != null) {
final timeSince = now.difference(_lastStreamInfoUpdate!);
if (timeSince.inSeconds < 5) return;
}
_lastStreamInfoUpdate = now;
final request = _updateStreamInfoInternal();
_streamInfoRequest = request;
try {
await request;
} finally {
if (identical(_streamInfoRequest, request)) {
_streamInfoRequest = null;
}
}
}
Future<void> _updateStreamInfoInternal() async {
try {
final info = await twitchApi.getStream(userLogin: userLogin);
runInAction(() {
_streamInfo = info;
_offlineChannelInfo = null;
});
} catch (e) {
Channel? channel;
try {
channel = await twitchApi.getChannel(userId: userId);
} catch (_) {}
runInAction(() {
_overlayTimer?.cancel();
_streamInfo = null;
_offlineChannelInfo = channel;
});
}
}
@override
@action
void handleAppResume() {
updateStreamInfo(forceUpdate: true);
if (!_userPaused && _controller != null) {
_controller!.play();
}
}
@override
@action
void dispose() {
_disposed = true;
if (Platform.isAndroid) {
SimplePip.isAutoPipAvailable.then((isAutoPipAvailable) {
if (isAutoPipAvailable) _pip.setAutoPipMode(autoEnter: false);
});
}
_overlayTimer?.cancel();
_latencyTimer?.cancel();
_stallRecoveryTimer?.cancel();
_pipSub?.cancel();
_qualitiesSub?.cancel();
_disposeAndroidAutoPipReaction?.call();
_controller?.removeActivityListener(_handleActivityEvent);
_controller?.dispose();
_controller = null;
}
}

View file

@ -0,0 +1,375 @@
// 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 {
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 _$_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 _$_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 _$_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 _$handleRefreshAsyncAction = AsyncAction(
'NativeVideoStoreBase.handleRefresh',
context: context,
);
@override
Future<void> handleRefresh() {
return _$handleRefreshAsyncAction.run(() => super.handleRefresh());
}
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
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
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 dispose() {
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
name: 'NativeVideoStoreBase.dispose',
);
try {
return super.dispose();
} finally {
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''
''';
}
}

View file

@ -3,10 +3,12 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.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/details/chat_users_list.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.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/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/screens/settings/stores/settings_store.dart';
import 'package:frosty/theme.dart'; import 'package:frosty/theme.dart';
import 'package:frosty/utils.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. /// Creates a widget containing controls which enable interactions with an underlying [Video] widget.
class VideoOverlay extends StatelessWidget { class VideoOverlay extends StatelessWidget {
final VideoStore videoStore; final VideoPlayerInterface videoStore;
final ChatStore chatStore; final ChatStore chatStore;
final SettingsStore settingsStore; final SettingsStore settingsStore;
@ -108,7 +110,7 @@ class VideoOverlay extends StatelessWidget {
videoStore.setStreamQuality(quality); videoStore.setStreamQuality(quality);
SharedPreferences.getInstance().then( SharedPreferences.getInstance().then(
(prefs) => prefs.setString( (prefs) => prefs.setString(
'last_stream_quality', kLastStreamQualityKey,
quality, quality,
), ),
); );
@ -259,7 +261,6 @@ class VideoOverlay extends StatelessWidget {
displayName: chatStore.displayName, displayName: chatStore.displayName,
showUptime: false, showUptime: false,
showViewerCount: false, showViewerCount: false,
showOfflineIndicator: false,
textColor: surfaceColor, textColor: surfaceColor,
isOffline: true, isOffline: true,
isInSharedChatMode: isInSharedChatMode:
@ -344,7 +345,10 @@ class VideoOverlay extends StatelessWidget {
if (videoStore.settingsStore.fullScreen && if (videoStore.settingsStore.fullScreen &&
context.isLandscape) context.isLandscape)
chatOverlayButton, chatOverlayButton,
if (!Platform.isIOS || isIPad()) videoSettingsButton, if (!Platform.isIOS ||
isIPad() ||
videoStore is NativeVideoStore)
videoSettingsButton,
], ],
), ),
Center( Center(

View file

@ -0,0 +1,31 @@
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.
abstract class VideoPlayerInterface {
SettingsStore get settingsStore;
bool get loading;
bool get paused;
bool get overlayVisible;
bool get isInPipMode;
StreamTwitch? get streamInfo;
Channel? get offlineChannelInfo;
List<String> get availableStreamQualities;
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();
}

View file

@ -5,8 +5,10 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/models/channel.dart'; import 'package:frosty/models/channel.dart';
import 'package:frosty/models/stream.dart'; import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/channel/video/video_player_interface.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:mobx/mobx.dart'; import 'package:mobx/mobx.dart';
@ -20,7 +22,7 @@ part 'video_store.g.dart';
class VideoStore = VideoStoreBase with _$VideoStore; class VideoStore = VideoStoreBase with _$VideoStore;
abstract class VideoStoreBase with Store { abstract class VideoStoreBase with Store implements VideoPlayerInterface {
final TwitchApi twitchApi; final TwitchApi twitchApi;
/// The userlogin of the current channel. /// The userlogin of the current channel.
@ -31,6 +33,7 @@ abstract class VideoStoreBase with Store {
final AuthStore authStore; final AuthStore authStore;
@override
final SettingsStore settingsStore; final SettingsStore settingsStore;
/// The [SimplePip] instance used for initiating PiP on Android. /// The [SimplePip] instance used for initiating PiP on Android.
@ -86,7 +89,7 @@ abstract class VideoStoreBase with Store {
return; return;
} }
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final lastStreamQuality = prefs.getString('last_stream_quality'); final lastStreamQuality = prefs.getString(kLastStreamQualityKey);
if (lastStreamQuality == null) return; if (lastStreamQuality == null) return;
setStreamQuality(lastStreamQuality); setStreamQuality(lastStreamQuality);
} }
@ -102,6 +105,7 @@ abstract class VideoStoreBase with Store {
..addJavaScriptChannel( ..addJavaScriptChannel(
'VideoPlaying', 'VideoPlaying',
onMessageReceived: (message) { onMessageReceived: (message) {
_loading = false;
_paused = false; _paused = false;
if (Platform.isAndroid) pip.setIsPlaying(true); if (Platform.isAndroid) pip.setIsPlaying(true);
}, },
@ -169,6 +173,9 @@ abstract class VideoStoreBase with Store {
/// Disposes the latency settings reaction. /// Disposes the latency settings reaction.
ReactionDisposer? _disposeLatencySettingsReaction; ReactionDisposer? _disposeLatencySettingsReaction;
@readonly
var _loading = true;
/// If the video is currently paused. /// If the video is currently paused.
/// ///
/// Does not pause or play the video, only used for rendering state of the overlay. /// Does not pause or play the video, only used for rendering state of the overlay.
@ -195,6 +202,7 @@ abstract class VideoStoreBase with Store {
int _streamQualityIndex = 0; int _streamQualityIndex = 0;
// The current stream quality string // The current stream quality string
@override
String get streamQuality => String get streamQuality =>
_availableStreamQualities.elementAtOrNull(_streamQualityIndex) ?? 'Auto'; _availableStreamQualities.elementAtOrNull(_streamQualityIndex) ?? 'Auto';
@ -343,6 +351,7 @@ abstract class VideoStoreBase with Store {
); );
} }
@override
@action @action
Future<void> updateStreamQualities() async { Future<void> updateStreamQualities() async {
try { try {
@ -382,6 +391,7 @@ abstract class VideoStoreBase with Store {
} }
} }
@override
@action @action
Future<void> setStreamQuality(String newStreamQuality) async { Future<void> setStreamQuality(String newStreamQuality) async {
final indexOfStreamQuality = _availableStreamQualities.indexOf( final indexOfStreamQuality = _availableStreamQualities.indexOf(
@ -842,6 +852,7 @@ abstract class VideoStoreBase with Store {
} }
/// Called whenever the video/overlay is tapped. /// Called whenever the video/overlay is tapped.
@override
@action @action
void handleVideoTap() { void handleVideoTap() {
if (_isInPipMode) { if (_isInPipMode) {
@ -918,6 +929,7 @@ abstract class VideoStoreBase with Store {
} }
/// Handles app resume event for immediate stream info refresh in chat-only mode. /// Handles app resume event for immediate stream info refresh in chat-only mode.
@override
@action @action
void handleAppResume() { void handleAppResume() {
// Only refresh immediately in chat-only mode // Only refresh immediately in chat-only mode
@ -930,6 +942,7 @@ abstract class VideoStoreBase with Store {
/// ///
/// If the stream is offline, fetches channel information to show offline details. /// If the stream is offline, fetches channel information to show offline details.
/// Set [forceUpdate] to true to bypass the rate limiting check. /// Set [forceUpdate] to true to bypass the rate limiting check.
@override
@action @action
Future<void> updateStreamInfo({bool forceUpdate = false}) async { Future<void> updateStreamInfo({bool forceUpdate = false}) async {
if (_streamInfoRequest != null) { if (_streamInfoRequest != null) {
@ -967,6 +980,7 @@ abstract class VideoStoreBase with Store {
_offlineChannelInfo = null; _offlineChannelInfo = null;
} catch (e) { } catch (e) {
_overlayTimer?.cancel(); _overlayTimer?.cancel();
_loading = false;
_streamInfo = null; _streamInfo = null;
_paused = true; _paused = true;
@ -987,6 +1001,7 @@ abstract class VideoStoreBase with Store {
/// Handles the toggle overlay options. /// Handles the toggle overlay options.
/// ///
/// The toggle overlay option allows switching between the custom and Twitch's overlay by long-pressing the overlay. /// The toggle overlay option allows switching between the custom and Twitch's overlay by long-pressing the overlay.
@override
@action @action
void handleToggleOverlay() { void handleToggleOverlay() {
if (settingsStore.toggleableOverlay) { if (settingsStore.toggleableOverlay) {
@ -1004,9 +1019,11 @@ abstract class VideoStoreBase with Store {
} }
/// Refreshes the stream webview and updates the stream info. /// Refreshes the stream webview and updates the stream info.
@override
@action @action
Future<void> handleRefresh() async { Future<void> handleRefresh() async {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
_loading = true;
_paused = true; _paused = true;
_firstTimeSettingQuality = true; _firstTimeSettingQuality = true;
_isInPipMode = false; _isInPipMode = false;
@ -1028,6 +1045,7 @@ abstract class VideoStoreBase with Store {
} }
/// Play or pause the video depending on the current state of [_paused]. /// Play or pause the video depending on the current state of [_paused].
@override
void handlePausePlay() { void handlePausePlay() {
try { try {
if (_paused) { if (_paused) {
@ -1048,6 +1066,7 @@ abstract class VideoStoreBase with Store {
/// ///
/// On Android, this will utilize the native Android PiP API. /// On Android, this will utilize the native Android PiP API.
/// On iOS, this will utilize the web picture-in-picture API. /// On iOS, this will utilize the web picture-in-picture API.
@override
void requestPictureInPicture() { void requestPictureInPicture() {
try { try {
if (Platform.isAndroid) { if (Platform.isAndroid) {
@ -1067,6 +1086,7 @@ abstract class VideoStoreBase with Store {
/// If not in PiP mode, enters PiP mode. /// If not in PiP mode, enters PiP mode.
/// If already in PiP mode on iOS, exits PiP mode. /// If already in PiP mode on iOS, exits PiP mode.
/// On Android, always enters PiP mode (no programmatic exit or state tracking). /// On Android, always enters PiP mode (no programmatic exit or state tracking).
@override
@action @action
void togglePictureInPicture() { void togglePictureInPicture() {
try { try {
@ -1088,6 +1108,7 @@ abstract class VideoStoreBase with Store {
} }
} }
@override
@action @action
void dispose() { void dispose() {
// Disable auto PiP when leaving so that we don't enter PiP on other screens. // Disable auto PiP when leaving so that we don't enter PiP on other screens.

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 // 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 { 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( late final _$_pausedAtom = Atom(
name: 'VideoStoreBase._paused', name: 'VideoStoreBase._paused',
context: context, context: context,

View file

@ -0,0 +1,284 @@
import 'dart:async';
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:frosty/models/gql_chat_comment.dart';
import 'package:frosty/models/irc.dart';
import 'package:frosty/screens/channel/chat/chat_render_context.dart';
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:mobx/mobx.dart';
part 'vod_chat_store.g.dart';
class VodChatStore = VodChatStoreBase with _$VodChatStore;
abstract class VodChatStoreBase with Store implements ChatRenderContext {
/// The total maximum amount of messages in chat.
static const _messageLimit = 5000;
/// The maximum amount of messages to render when autoscroll is enabled.
static const _renderMessageLimit = 100;
@override
final ChatAssetsStore assetsStore;
@override
final AuthStore auth;
@override
final SettingsStore settings;
@override
final String channelId;
@override
final revealedMessageIds = ObservableSet<String>();
final TwitchGqlApi twitchGqlApi;
final String videoId;
/// The scroll controller that controls auto-scroll and resume-scroll behavior.
final scrollController = ScrollController();
/// The amount of messages to free (remove) when the [_messageLimit] is reached.
final _messagesToRemove = (_messageLimit * 0.2).toInt();
/// The periodic timer used for batching chat message re-renders.
Timer? _messageBufferTimer;
/// The list of chat messages to add once autoscroll is resumed.
final messageBuffer = ObservableList<IRCMessage>();
/// Timer used for dismissing the notification.
Timer? _notificationTimer;
/// Fetched GQL comments waiting to be advanced based on playback position.
final _commentBuffer = Queue<GqlChatComment>();
/// Cursor for fetching the next page of comments.
String? _nextCursor;
/// Whether we're currently fetching comments from the API.
bool _isFetching = false;
/// The last rendered content offset to avoid duplicates.
int _lastRenderedOffset = -1;
/// A notification message to display above the chat.
@readonly
String? _notification;
/// The list of chat messages to render and display.
@readonly
var _messages = ObservableList<IRCMessage>();
/// If the chat should automatically scroll/jump to the latest message.
@readonly
var _autoScroll = true;
/// Current playback position in seconds.
@observable
int playbackPosition = 0;
/// Whether the store has been initialized.
@readonly
var _isInitialized = false;
/// The list of chat messages that should be rendered. Used to prevent jank when resuming scroll.
@computed
List<IRCMessage> get renderMessages {
// If autoscroll is disabled, render ALL messages in chat.
// The second condition is to prevent an out of index error with sublist.
if (!_autoScroll || _messages.length < _renderMessageLimit) {
return _messages;
}
// When autoscroll is enabled, only show the first [_renderMessageLimit] messages.
return _messages.sublist(_messages.length - _renderMessageLimit);
}
VodChatStoreBase({
required this.assetsStore,
required this.auth,
required this.settings,
required this.channelId,
required this.twitchGqlApi,
required this.videoId,
});
Future<void> init() async {
await Future.wait([
assetsStore.init().then((_) => _loadAssets()),
_fetchComments(contentOffsetSeconds: 0),
]);
_startMessageBufferTimer();
_setupScrollListener();
_isInitialized = true;
}
Future<void> _loadAssets() async {
await assetsStore.assetsFuture(
channelId: channelId,
headers: auth.headersTwitch,
onEmoteError: (error) {
debugPrint(error.toString());
return <dynamic>[];
},
onBadgeError: (error) {
debugPrint(error.toString());
return <dynamic>[];
},
showTwitchEmotes: settings.showTwitchEmotes,
showTwitchBadges: settings.showTwitchBadges,
show7TVEmotes: settings.show7TVEmotes,
showBTTVEmotes: settings.showBTTVEmotes,
showBTTVBadges: settings.showBTTVBadges,
showFFZEmotes: settings.showFFZEmotes,
showFFZBadges: settings.showFFZBadges,
);
}
void _startMessageBufferTimer() {
_messageBufferTimer = Timer.periodic(
const Duration(milliseconds: 200),
(_) => addMessages(),
);
}
void _setupScrollListener() {
scrollController.addListener(() {
if (!scrollController.hasClients) return;
final isAtBottom = scrollController.offset <= 0;
if (isAtBottom != _autoScroll) {
_autoScroll = isAtBottom;
if (_autoScroll) addMessages();
}
});
}
@action
void addMessages() {
if (!_autoScroll || messageBuffer.isEmpty) return;
_messages.addAll(messageBuffer);
messageBuffer.clear();
if (_messages.length > _messageLimit) {
_messages.removeRange(0, _messagesToRemove);
}
}
/// Re-enables [_autoScroll] and jumps to the latest message.
@action
void resumeScroll() {
_autoScroll = true;
scrollController.jumpTo(0);
WidgetsBinding.instance.addPostFrameCallback((_) {
scrollController.jumpTo(0);
});
}
@override
@action
void revealMessage(String id) {
revealedMessageIds.add(id);
}
@override
@action
void updateNotification(String message) {
_notificationTimer?.cancel();
_notification = message;
_notificationTimer = Timer(const Duration(seconds: 5), () {
_notification = null;
});
}
@action
void clearNotification() {
_notificationTimer?.cancel();
_notification = null;
}
/// Called from the JS channel with the current video position.
@action
void updatePlaybackPosition(int offsetSeconds) {
final previousPosition = playbackPosition;
playbackPosition = offsetSeconds;
// Seek detection: if position jumped significantly, re-fetch.
final delta = offsetSeconds - previousPosition;
if (delta < -5 || delta > 10) {
handleSeek(offsetSeconds);
return;
}
// Advance comments from buffer to messageBuffer.
_advanceComments(offsetSeconds);
// Pre-fetch when running low.
if (_commentBuffer.length < 50 && !_isFetching && _nextCursor != null) {
_fetchComments(cursor: _nextCursor);
}
}
void _advanceComments(int offsetSeconds) {
while (_commentBuffer.isNotEmpty &&
_commentBuffer.first.contentOffsetSeconds <= offsetSeconds) {
final comment = _commentBuffer.removeFirst();
if (comment.contentOffsetSeconds > _lastRenderedOffset ||
_lastRenderedOffset == -1) {
_lastRenderedOffset = comment.contentOffsetSeconds;
messageBuffer.add(IRCMessage.fromGqlComment(comment));
}
}
}
/// Handles an explicit seek to [offsetSeconds], clearing state and re-fetching.
@action
Future<void> handleSeek(int offsetSeconds) async {
playbackPosition = offsetSeconds;
_commentBuffer.clear();
messageBuffer.clear();
_messages.clear();
_lastRenderedOffset = -1;
_nextCursor = null;
await _fetchComments(contentOffsetSeconds: offsetSeconds);
}
Future<void> _fetchComments({
int? contentOffsetSeconds,
String? cursor,
}) async {
if (_isFetching) return;
_isFetching = true;
try {
final response = await twitchGqlApi.getVideoComments(
videoId: videoId,
contentOffsetSeconds: contentOffsetSeconds,
cursor: cursor,
);
_commentBuffer.addAll(response.comments);
if (response.hasNextPage) {
_nextCursor = response.cursor;
} else {
_nextCursor = null;
}
} catch (e) {
debugPrint('Failed to fetch VOD comments: $e');
} finally {
_isFetching = false;
}
}
void dispose() {
_messageBufferTimer?.cancel();
_notificationTimer?.cancel();
scrollController.dispose();
}
}

View file

@ -0,0 +1,203 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'vod_chat_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 _$VodChatStore on VodChatStoreBase, Store {
Computed<List<IRCMessage>>? _$renderMessagesComputed;
@override
List<IRCMessage> get renderMessages =>
(_$renderMessagesComputed ??= Computed<List<IRCMessage>>(
() => super.renderMessages,
name: 'VodChatStoreBase.renderMessages',
)).value;
late final _$_notificationAtom = Atom(
name: 'VodChatStoreBase._notification',
context: context,
);
String? get notification {
_$_notificationAtom.reportRead();
return super._notification;
}
@override
String? get _notification => notification;
@override
set _notification(String? value) {
_$_notificationAtom.reportWrite(value, super._notification, () {
super._notification = value;
});
}
late final _$_messagesAtom = Atom(
name: 'VodChatStoreBase._messages',
context: context,
);
ObservableList<IRCMessage> get messages {
_$_messagesAtom.reportRead();
return super._messages;
}
@override
ObservableList<IRCMessage> get _messages => messages;
@override
set _messages(ObservableList<IRCMessage> value) {
_$_messagesAtom.reportWrite(value, super._messages, () {
super._messages = value;
});
}
late final _$_autoScrollAtom = Atom(
name: 'VodChatStoreBase._autoScroll',
context: context,
);
bool get autoScroll {
_$_autoScrollAtom.reportRead();
return super._autoScroll;
}
@override
bool get _autoScroll => autoScroll;
@override
set _autoScroll(bool value) {
_$_autoScrollAtom.reportWrite(value, super._autoScroll, () {
super._autoScroll = value;
});
}
late final _$playbackPositionAtom = Atom(
name: 'VodChatStoreBase.playbackPosition',
context: context,
);
@override
int get playbackPosition {
_$playbackPositionAtom.reportRead();
return super.playbackPosition;
}
@override
set playbackPosition(int value) {
_$playbackPositionAtom.reportWrite(value, super.playbackPosition, () {
super.playbackPosition = value;
});
}
late final _$_isInitializedAtom = Atom(
name: 'VodChatStoreBase._isInitialized',
context: context,
);
bool get isInitialized {
_$_isInitializedAtom.reportRead();
return super._isInitialized;
}
@override
bool get _isInitialized => isInitialized;
@override
set _isInitialized(bool value) {
_$_isInitializedAtom.reportWrite(value, super._isInitialized, () {
super._isInitialized = value;
});
}
late final _$VodChatStoreBaseActionController = ActionController(
name: 'VodChatStoreBase',
context: context,
);
@override
void addMessages() {
final _$actionInfo = _$VodChatStoreBaseActionController.startAction(
name: 'VodChatStoreBase.addMessages',
);
try {
return super.addMessages();
} finally {
_$VodChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void resumeScroll() {
final _$actionInfo = _$VodChatStoreBaseActionController.startAction(
name: 'VodChatStoreBase.resumeScroll',
);
try {
return super.resumeScroll();
} finally {
_$VodChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void revealMessage(String id) {
final _$actionInfo = _$VodChatStoreBaseActionController.startAction(
name: 'VodChatStoreBase.revealMessage',
);
try {
return super.revealMessage(id);
} finally {
_$VodChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void updateNotification(String message) {
final _$actionInfo = _$VodChatStoreBaseActionController.startAction(
name: 'VodChatStoreBase.updateNotification',
);
try {
return super.updateNotification(message);
} finally {
_$VodChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void clearNotification() {
final _$actionInfo = _$VodChatStoreBaseActionController.startAction(
name: 'VodChatStoreBase.clearNotification',
);
try {
return super.clearNotification();
} finally {
_$VodChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void updatePlaybackPosition(int offsetSeconds) {
final _$actionInfo = _$VodChatStoreBaseActionController.startAction(
name: 'VodChatStoreBase.updatePlaybackPosition',
);
try {
return super.updatePlaybackPosition(offsetSeconds);
} finally {
_$VodChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''
playbackPosition: ${playbackPosition},
renderMessages: ${renderMessages}
''';
}
}

View file

@ -0,0 +1,73 @@
import 'package:flutter/foundation.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/models/video.dart';
import 'package:mobx/mobx.dart';
part 'vod_list_store.g.dart';
class VodListStore = VodListStoreBase with _$VodListStore;
abstract class VodListStoreBase with Store {
final TwitchApi twitchApi;
final String userId;
@readonly
var _videos = ObservableList<VideoTwitch>();
@readonly
var _isLoading = false;
@readonly
var _error = '';
String? _cursor;
bool _hasMore = true;
VodListStoreBase({
required this.twitchApi,
required this.userId,
});
@action
Future<void> fetchVideos() async {
_isLoading = true;
_error = '';
try {
final response = await twitchApi.getVideos(userId: userId);
_videos = response.data.asObservable();
_cursor = response.pagination['cursor'];
_hasMore = response.data.length >= 20;
} catch (e) {
_error = e.toString();
debugPrint('Failed to fetch VODs: $e');
} finally {
_isLoading = false;
}
}
@action
Future<void> fetchMore() async {
if (_isLoading || !_hasMore || _cursor == null) return;
_isLoading = true;
try {
final response = await twitchApi.getVideos(
userId: userId,
cursor: _cursor,
);
_videos.addAll(response.data);
_cursor = response.pagination['cursor'];
_hasMore = response.data.length >= 20;
} catch (e) {
debugPrint('Failed to fetch more VODs: $e');
} finally {
_isLoading = false;
}
}
@action
Future<void> refresh() async {
_cursor = null;
_hasMore = true;
await fetchVideos();
}
}

View file

@ -0,0 +1,108 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'vod_list_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 _$VodListStore on VodListStoreBase, Store {
late final _$_videosAtom = Atom(
name: 'VodListStoreBase._videos',
context: context,
);
ObservableList<VideoTwitch> get videos {
_$_videosAtom.reportRead();
return super._videos;
}
@override
ObservableList<VideoTwitch> get _videos => videos;
@override
set _videos(ObservableList<VideoTwitch> value) {
_$_videosAtom.reportWrite(value, super._videos, () {
super._videos = value;
});
}
late final _$_isLoadingAtom = Atom(
name: 'VodListStoreBase._isLoading',
context: context,
);
bool get isLoading {
_$_isLoadingAtom.reportRead();
return super._isLoading;
}
@override
bool get _isLoading => isLoading;
@override
set _isLoading(bool value) {
_$_isLoadingAtom.reportWrite(value, super._isLoading, () {
super._isLoading = value;
});
}
late final _$_errorAtom = Atom(
name: 'VodListStoreBase._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 _$fetchVideosAsyncAction = AsyncAction(
'VodListStoreBase.fetchVideos',
context: context,
);
@override
Future<void> fetchVideos() {
return _$fetchVideosAsyncAction.run(() => super.fetchVideos());
}
late final _$fetchMoreAsyncAction = AsyncAction(
'VodListStoreBase.fetchMore',
context: context,
);
@override
Future<void> fetchMore() {
return _$fetchMoreAsyncAction.run(() => super.fetchMore());
}
late final _$refreshAsyncAction = AsyncAction(
'VodListStoreBase.refresh',
context: context,
);
@override
Future<void> refresh() {
return _$refreshAsyncAction.run(() => super.refresh());
}
@override
String toString() {
return '''
''';
}
}

View file

@ -0,0 +1,184 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/models/video.dart';
import 'package:frosty/screens/channel/vod/vod_player.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/frosty_cached_network_image.dart';
import 'package:frosty/widgets/skeleton_loader.dart';
/// A tappable card widget that displays a VOD's thumbnail and details.
class VodCard extends StatelessWidget {
final VideoTwitch video;
const VodCard({super.key, required this.video});
String _formatDuration(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
if (hours > 0) {
return '${hours}h${minutes.toString().padLeft(2, '0')}m${seconds.toString().padLeft(2, '0')}s';
}
return '${minutes}m${seconds.toString().padLeft(2, '0')}s';
}
String _formatRelativeDate(String createdAt) {
final date = DateTime.tryParse(createdAt);
if (date == null) return createdAt;
final now = DateTime.now().toUtc();
final difference = now.difference(date);
if (difference.inDays >= 365) {
final years = difference.inDays ~/ 365;
return '$years ${years == 1 ? 'year' : 'years'} ago';
} else if (difference.inDays >= 30) {
final months = difference.inDays ~/ 30;
return '$months ${months == 1 ? 'month' : 'months'} ago';
} else if (difference.inDays >= 1) {
return '${difference.inDays} ${difference.inDays == 1 ? 'day' : 'days'} ago';
} else if (difference.inHours >= 1) {
return '${difference.inHours} ${difference.inHours == 1 ? 'hour' : 'hours'} ago';
} else {
return '${difference.inMinutes} ${difference.inMinutes == 1 ? 'minute' : 'minutes'} ago';
}
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final thumbnailWidth = min((size.width * pixelRatio) ~/ 3, 1920);
final thumbnailHeight = min((thumbnailWidth * (9 / 16)).toInt(), 1080);
final duration = parseTwitchDuration(video.duration);
final thumbnailUrl = video.thumbnailUrl
.replaceFirst('%{width}', '$thumbnailWidth')
.replaceFirst('%{height}', '$thumbnailHeight');
final thumbnail = AspectRatio(
aspectRatio: 16 / 9,
child: thumbnailUrl.isEmpty
? Container(color: Colors.black12)
: FrostyCachedNetworkImage(
imageUrl: thumbnailUrl,
placeholder: (context, url) => const SkeletonLoader(
borderRadius: kCardBorderRadius,
),
useOldImageOnUrlChange: true,
),
);
final fontColor = DefaultTextStyle.of(context).style.color;
const subFontSize = 14.0;
final imageSection = ClipRRect(
borderRadius: kCardBorderRadius,
child: Stack(
alignment: AlignmentDirectional.bottomEnd,
children: [
thumbnail,
Container(
margin: const EdgeInsets.all(3),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: Colors.white.withValues(alpha: 0.08),
width: 0.5,
),
),
child: Text(
_formatDuration(duration),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: context.frostyColors.overlayOnSurface,
),
),
),
],
),
);
final vodInfoSection = Padding(
padding: const EdgeInsets.only(left: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 2,
children: [
Tooltip(
message: video.title.trim(),
preferBelow: false,
child: Text(
video.title.trim(),
overflow: TextOverflow.ellipsis,
maxLines: 2,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: fontColor,
),
),
),
Text(
_formatRelativeDate(video.createdAt),
style: TextStyle(
fontSize: subFontSize,
color: fontColor?.withValues(alpha: 0.8),
),
),
Text(
'${_formatViewCount(video.viewCount)} views',
style: TextStyle(
fontSize: subFontSize,
color: fontColor?.withValues(alpha: 0.8),
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
);
return InkWell(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => VodPlayer(
videoId: video.id,
channelId: video.userId,
title: video.title,
),
),
),
child: Padding(
padding: EdgeInsets.only(
top: 8,
bottom: 8,
left: 16 + MediaQuery.of(context).padding.left,
right: 16 + MediaQuery.of(context).padding.right,
),
child: Row(
children: [
Flexible(child: imageSection),
Flexible(flex: 2, child: vodInfoSection),
],
),
),
);
}
String _formatViewCount(int count) {
if (count >= 1000000) {
return '${(count / 1000000).toStringAsFixed(1)}M';
} else if (count >= 1000) {
return '${(count / 1000).toStringAsFixed(1)}K';
}
return count.toString();
}
}

View file

@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/vod/stores/vod_list_store.dart';
import 'package:frosty/screens/channel/vod/vod_card.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/alert_message.dart';
class VodList extends StatefulWidget {
final String userId;
final String displayName;
const VodList({
super.key,
required this.userId,
required this.displayName,
});
@override
State<VodList> createState() => _VodListState();
}
class _VodListState extends State<VodList> {
late final VodListStore _store = VodListStore(
twitchApi: context.twitchApi,
userId: widget.userId,
);
@override
void initState() {
super.initState();
_store.fetchVideos();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('${widget.displayName} — Past broadcasts')),
body: Observer(
builder: (context) {
if (_store.error.isNotEmpty && _store.videos.isEmpty) {
return Center(
child: AlertMessage(message: _store.error),
);
}
if (_store.videos.isEmpty && !_store.isLoading) {
return const Center(
child: AlertMessage(message: 'No past broadcasts found'),
);
}
return RefreshIndicator.adaptive(
onRefresh: _store.refresh,
child: NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is ScrollEndNotification &&
notification.metrics.extentAfter < 200) {
_store.fetchMore();
}
return false;
},
child: ListView.builder(
itemCount: _store.videos.length + (_store.isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (index == _store.videos.length) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator.adaptive(),
),
);
}
return VodCard(video: _store.videos[index]);
},
),
),
);
},
),
);
}
}

View file

@ -0,0 +1,282 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
import 'package:frosty/screens/channel/vod/stores/vod_chat_store.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/frosty_notification.dart';
import 'package:frosty/widgets/frosty_scrollbar.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
/// VOD player + chat replay screen.
///
/// Layout matches the live channel view [VideoChat]: no AppBar,
/// video on top (16:9), chat below (expanded).
class VodPlayer extends StatefulWidget {
final String videoId;
final String channelId;
final String title;
const VodPlayer({
super.key,
required this.videoId,
required this.channelId,
required this.title,
});
@override
State<VodPlayer> createState() => _VodPlayerState();
}
class _VodPlayerState extends State<VodPlayer> {
late final VodChatStore _vodChatStore;
late final WebViewController _webViewController;
String get _videoUrl =>
'https://player.twitch.tv/?video=v${widget.videoId}&parent=frosty&muted=false';
@override
void initState() {
super.initState();
_vodChatStore = VodChatStore(
assetsStore: ChatAssetsStore(
twitchApi: context.twitchApi,
bttvApi: context.bttvApi,
ffzApi: context.ffzApi,
sevenTVApi: context.sevenTVApi,
globalAssetsStore: context.globalAssetsStore,
),
auth: context.authStore,
settings: context.settingsStore,
channelId: widget.channelId,
twitchGqlApi: context.twitchGqlApi,
videoId: widget.videoId,
);
_vodChatStore.init();
_initWebView();
}
void _initWebView() {
// Platform-specific params for autoplay.
late final PlatformWebViewControllerCreationParams params;
if (WebViewPlatform.instance is WebKitWebViewPlatform) {
params = WebKitWebViewControllerCreationParams(
allowsInlineMediaPlayback: true,
mediaTypesRequiringUserAction: const <PlaybackMediaTypes>{},
);
} else {
params = const PlatformWebViewControllerCreationParams();
}
_webViewController =
WebViewController.fromPlatformCreationParams(params)
..setBackgroundColor(Colors.black)
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel(
'VodPosition',
onMessageReceived: (message) {
final text = message.message;
if (text.startsWith('seek:')) {
final seconds =
double.tryParse(text.substring(5))?.toInt() ?? 0;
_vodChatStore.handleSeek(seconds);
} else {
final seconds = double.tryParse(text)?.toInt() ?? 0;
_vodChatStore.updatePlaybackPosition(seconds);
}
},
)
..setNavigationDelegate(
NavigationDelegate(
onPageFinished: (url) async {
if (!url.contains('player.twitch.tv')) return;
await _injectPositionTracker();
},
),
);
// Android: disable user gesture requirement for autoplay.
if (_webViewController.platform is AndroidWebViewController) {
(_webViewController.platform as AndroidWebViewController)
.setMediaPlaybackRequiresUserGesture(false);
}
_webViewController.loadRequest(Uri.parse(_videoUrl));
}
Future<void> _injectPositionTracker() async {
// Wait for the video element to be available, then poll currentTime
// and listen for seek events.
await _webViewController.runJavaScript('''
(function() {
function tryAttach() {
var video = document.querySelector('video');
if (!video) {
setTimeout(tryAttach, 500);
return;
}
setInterval(function() {
VodPosition.postMessage(Math.floor(video.currentTime).toString());
}, 1000);
video.addEventListener('seeked', function() {
VodPosition.postMessage('seek:' + Math.floor(video.currentTime).toString());
});
}
tryAttach();
})();
''');
}
Widget _buildWebView() {
if (Platform.isAndroid &&
WebViewPlatform.instance is AndroidWebViewPlatform) {
return WebViewWidget.fromPlatformCreationParams(
params: AndroidWebViewWidgetCreationParams(
controller: _webViewController.platform,
displayWithHybridComposition:
!context.settingsStore.useTextureRendering,
),
);
}
return WebViewWidget(controller: _webViewController);
}
Widget _buildChatList() {
return Observer(
builder: (context) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: _vodChatStore.settings.messageScale.textScaler,
),
child: DefaultTextStyle(
style: context.defaultTextStyle.copyWith(
fontSize: _vodChatStore.settings.fontSize,
),
child: Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
FrostyScrollbar(
controller: _vodChatStore.scrollController,
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom,
),
child: Observer(
builder: (context) {
final messages = _vodChatStore.renderMessages;
return ListView.builder(
reverse: true,
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom,
),
addAutomaticKeepAlives: false,
controller: _vodChatStore.scrollController,
itemCount: messages.length,
itemBuilder: (context, index) => ChatMessage(
ircMessage:
messages[messages.length - 1 - index],
chatStore: _vodChatStore,
),
);
},
),
),
_buildResumeScrollButton(),
],
),
),
);
},
);
}
Widget _buildResumeScrollButton() {
return Builder(
builder: (context) {
return AnimatedPadding(
duration: const Duration(milliseconds: 200),
padding: EdgeInsets.only(
left: 4,
top: 4,
right: 4,
bottom: MediaQuery.of(context).padding.bottom,
),
child: Observer(
builder: (_) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: _vodChatStore.autoScroll
? null
: ElevatedButton.icon(
onPressed: _vodChatStore.resumeScroll,
icon: const Icon(Icons.arrow_downward_rounded),
label: Text(
_vodChatStore.messageBuffer.isNotEmpty
? '${_vodChatStore.messageBuffer.length} new ${_vodChatStore.messageBuffer.length == 1 ? 'message' : 'messages'}'
: 'Resume scroll',
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
),
),
);
},
),
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
bottom: false,
child: Column(
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: _buildWebView(),
),
Expanded(
child: Stack(
children: [
_buildChatList(),
Observer(
builder: (_) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: _vodChatStore.notification != null
? Align(
alignment: Alignment.topCenter,
child: FrostyNotification(
message: _vodChatStore.notification!,
onDismissed: _vodChatStore.clearNotification,
),
)
: null,
),
),
],
),
),
],
),
),
);
}
@override
void dispose() {
_vodChatStore.dispose();
super.dispose();
}
}

View file

@ -166,9 +166,8 @@ class _HomeState extends State<Home> {
NavigationDestination( NavigationDestination(
icon: Icon( icon: Icon(
Icons.favorite_border_rounded, Icons.favorite_border_rounded,
color: _homeStore.selectedIndex == 0 color: theme.colorScheme.onSurfaceVariant
? theme.colorScheme.onSurface .withValues(alpha: 0.5),
: theme.colorScheme.onSurfaceVariant,
), ),
selectedIcon: Icon( selectedIcon: Icon(
Icons.favorite_rounded, Icons.favorite_rounded,
@ -180,11 +179,8 @@ class _HomeState extends State<Home> {
NavigationDestination( NavigationDestination(
icon: Icon( icon: Icon(
Icons.arrow_upward_rounded, Icons.arrow_upward_rounded,
color: color: theme.colorScheme.onSurfaceVariant
_homeStore.selectedIndex == .withValues(alpha: 0.5),
(_authStore.isLoggedIn ? 1 : 0)
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant,
), ),
selectedIcon: Icon( selectedIcon: Icon(
Icons.arrow_upward_rounded, Icons.arrow_upward_rounded,
@ -196,11 +192,8 @@ class _HomeState extends State<Home> {
NavigationDestination( NavigationDestination(
icon: Icon( icon: Icon(
Icons.search_rounded, Icons.search_rounded,
color: color: theme.colorScheme.onSurfaceVariant
_homeStore.selectedIndex == .withValues(alpha: 0.5),
(_authStore.isLoggedIn ? 2 : 1)
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant,
), ),
selectedIcon: Icon( selectedIcon: Icon(
Icons.search_rounded, Icons.search_rounded,

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/account/account_options.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart'; import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:frosty/widgets/frosty_dialog.dart';
import 'package:frosty/widgets/profile_picture.dart'; import 'package:frosty/widgets/profile_picture.dart';
class ProfileCard extends StatelessWidget { class ProfileCard extends StatelessWidget {
@ -39,12 +40,69 @@ class ProfileCard extends StatelessWidget {
); );
} }
if (authStore.isLoggedIn && authStore.user.details != null) { if (authStore.isLoggedIn && authStore.user.details != null) {
final hasToken = authStore.gqlToken != null;
return ListTile( return ListTile(
leading: ProfilePicture( leading: ProfilePicture(
userLogin: authStore.user.details!.login, userLogin: authStore.user.details!.login,
radius: 12, radius: 12,
), ),
title: Text(authStore.user.details!.displayName), title: GestureDetector(
onTap: () {
showDialog(
context: context,
builder: (context) => 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: [
if (!hasToken)
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Cancel'),
),
if (!hasToken)
FilledButton(
onPressed: () {
Navigator.pop(context);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LoginWebView(),
),
);
},
child: const Text('Log in'),
),
if (hasToken)
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('OK'),
),
],
),
);
},
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), trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => _showAccountOptionsModalBottomSheet(context), onTap: () => _showAccountOptionsModalBottomSheet(context),
); );

View file

@ -1,347 +1,369 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/constants.dart'; import 'package:frosty/constants.dart';
import 'package:frosty/screens/settings/stores/settings_store.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_select.dart';
import 'package:frosty/screens/settings/widgets/settings_list_slider.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_list_switch.dart';
import 'package:frosty/screens/settings/widgets/settings_muted_words.dart'; import 'package:frosty/screens/settings/widgets/settings_muted_words.dart';
import 'package:frosty/utils/context_extensions.dart'; import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/frosty_cached_network_image.dart'; import 'package:frosty/widgets/frosty_cached_network_image.dart';
import 'package:frosty/widgets/section_header.dart'; import 'package:frosty/widgets/section_header.dart';
import 'package:frosty/widgets/settings_page_layout.dart'; import 'package:frosty/widgets/settings_page_layout.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
class ChatSettings extends StatefulWidget { class ChatSettings extends StatelessWidget {
final SettingsStore settingsStore; final SettingsStore settingsStore;
const ChatSettings({super.key, required this.settingsStore}); const ChatSettings({super.key, required this.settingsStore});
@override @override
State<ChatSettings> createState() => _ChatSettingsState(); Widget build(BuildContext context) {
} return Observer(
builder: (context) => SettingsPageLayout(
class _ChatSettingsState extends State<ChatSettings> { children: [
var showPreview = false; const SectionHeader('General', isFirst: true),
SettingsListSwitch(
@override title: 'Keep screen on',
Widget build(BuildContext context) { subtitle: const Text(
final settingsStore = widget.settingsStore; 'Prevents the screen from sleeping while a channel is open.',
),
return Observer( value: settingsStore.keepScreenAwake,
builder: (context) => SettingsPageLayout( onChanged: (newValue) =>
children: [ settingsStore.keepScreenAwake = newValue,
const SectionHeader('Message sizing', isFirst: true), ),
ExpansionTile( SettingsListSwitch(
title: const Text('Preview'), title: 'Autocomplete',
children: [ subtitle: const Text(
Container( 'Shows matching emotes and mentions while typing.',
width: double.infinity, ),
padding: const EdgeInsets.all(16), value: settingsStore.autocomplete,
child: DefaultTextStyle( onChanged: (newValue) =>
style: DefaultTextStyle.of( settingsStore.autocomplete = newValue,
context, ),
).style.copyWith(fontSize: settingsStore.fontSize), SettingsListSwitch(
child: Column( title: 'Load recent messages',
crossAxisAlignment: CrossAxisAlignment.start, subtitle: Text.rich(
children: [ TextSpan(
Text.rich( text:
TextSpan( 'Loads historical recent messages in chat through a third-party API service at ',
children: [ children: [
WidgetSpan( TextSpan(
alignment: PlaceholderAlignment.middle, text: 'https://recent-messages.robotty.de/',
child: FrostyCachedNetworkImage( style: TextStyle(
imageUrl: color: context.colorScheme.primary,
'https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/3', decoration: TextDecoration.underline,
height: decorationColor: context.colorScheme.primary,
defaultBadgeSize * settingsStore.badgeScale, ),
width: recognizer: TapGestureRecognizer()
defaultBadgeSize * settingsStore.badgeScale, ..onTap = () => launchUrl(
), Uri.parse(
), 'https://recent-messages.robotty.de/',
const TextSpan(text: ' Badge and emote preview. '), ),
WidgetSpan( mode: settingsStore.launchUrlExternal
alignment: PlaceholderAlignment.middle, ? LaunchMode.externalApplication
child: FrostyCachedNetworkImage( : LaunchMode.inAppBrowserView,
imageUrl: ),
'https://static-cdn.jtvnw.net/emoticons/v2/425618/default/dark/3.0', ),
height: ],
defaultEmoteSize * settingsStore.emoteScale, ),
width: ),
defaultEmoteSize * settingsStore.emoteScale, value: settingsStore.showRecentMessages,
), onChanged: (newValue) =>
), settingsStore.showRecentMessages = newValue,
], ),
), const SectionHeader('Message sizing'),
textScaler: settingsStore.messageScale.textScaler, ExpansionTile(
), title: const Text('Preview'),
SizedBox(height: settingsStore.messageSpacing), children: [
Text( Container(
'Hello! Here\'s a text preview.', width: double.infinity,
textScaler: settingsStore.messageScale.textScaler, padding: const EdgeInsets.all(16),
), child: DefaultTextStyle(
SizedBox(height: settingsStore.messageSpacing), style: DefaultTextStyle.of(
Text( context,
'And another for spacing without an emote!', ).style.copyWith(fontSize: settingsStore.fontSize),
textScaler: settingsStore.messageScale.textScaler, child: Column(
), crossAxisAlignment: CrossAxisAlignment.start,
], children: [
), Text.rich(
), TextSpan(
), children: [
], WidgetSpan(
), alignment: PlaceholderAlignment.middle,
SettingsListSlider( child: FrostyCachedNetworkImage(
title: 'Badge scale', imageUrl:
trailing: '${settingsStore.badgeScale.toStringAsFixed(2)}x', 'https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/3',
value: settingsStore.badgeScale, height:
min: 0.25, defaultBadgeSize *
max: 3.0, settingsStore.badgeScale,
divisions: 11, width:
onChanged: (newValue) => settingsStore.badgeScale = newValue, defaultBadgeSize *
), settingsStore.badgeScale,
SettingsListSlider( ),
title: 'Emote scale', ),
trailing: '${settingsStore.emoteScale.toStringAsFixed(2)}x', const TextSpan(
value: settingsStore.emoteScale, text: ' Badge and emote preview. ',
min: 0.25, ),
max: 3.0, WidgetSpan(
divisions: 11, alignment: PlaceholderAlignment.middle,
onChanged: (newValue) => settingsStore.emoteScale = newValue, child: FrostyCachedNetworkImage(
), imageUrl:
SettingsListSlider( 'https://static-cdn.jtvnw.net/emoticons/v2/425618/default/dark/3.0',
title: 'Message scale', height:
trailing: '${settingsStore.messageScale.toStringAsFixed(2)}x', defaultEmoteSize *
value: settingsStore.messageScale, settingsStore.emoteScale,
min: 0.5, width:
max: 2.0, defaultEmoteSize *
divisions: 6, settingsStore.emoteScale,
onChanged: (newValue) => settingsStore.messageScale = newValue, ),
), ),
SettingsListSlider( ],
title: 'Message spacing', ),
trailing: '${settingsStore.messageSpacing.toStringAsFixed(0)}px', textScaler: settingsStore.messageScale.textScaler,
value: settingsStore.messageSpacing, ),
max: 30.0, SizedBox(height: settingsStore.messageSpacing),
divisions: 15, Text(
onChanged: (newValue) => settingsStore.messageSpacing = newValue, 'Hello! Here\'s a text preview.',
), textScaler: settingsStore.messageScale.textScaler,
SettingsListSlider( ),
title: 'Font size', SizedBox(height: settingsStore.messageSpacing),
trailing: settingsStore.fontSize.toInt().toString(), Text(
value: settingsStore.fontSize, 'And another for spacing without an emote!',
min: 5, textScaler: settingsStore.messageScale.textScaler,
max: 20, ),
divisions: 15, ],
onChanged: (newValue) => settingsStore.fontSize = newValue, ),
), ),
const SectionHeader('Message appearance'), ),
SettingsListSwitch( ],
title: 'Show deleted messages', ),
subtitle: const Text( SettingsListSlider(
'Restores the original message of deleted messages.', title: 'Badge scale',
), trailing: '${settingsStore.badgeScale.toStringAsFixed(2)}x',
value: settingsStore.showDeletedMessages, value: settingsStore.badgeScale,
onChanged: (newValue) => min: 0.25,
settingsStore.showDeletedMessages = newValue, max: 3.0,
), divisions: 11,
SettingsListSwitch( onChanged: (newValue) => settingsStore.badgeScale = newValue,
title: 'Show message dividers', ),
value: settingsStore.showChatMessageDividers, SettingsListSlider(
onChanged: (newValue) => title: 'Emote scale',
settingsStore.showChatMessageDividers = newValue, trailing: '${settingsStore.emoteScale.toStringAsFixed(2)}x',
), value: settingsStore.emoteScale,
SettingsListSelect( min: 0.25,
title: 'Message timestamps', max: 3.0,
selectedOption: timestampNames[settingsStore.timestampType.index], divisions: 11,
options: timestampNames, onChanged: (newValue) => settingsStore.emoteScale = newValue,
onChanged: (newValue) => settingsStore.timestampType = ),
TimestampType.values[timestampNames.indexOf(newValue)], SettingsListSlider(
), title: 'Message scale',
const SectionHeader('Delay and latency'), trailing: '${settingsStore.messageScale.toStringAsFixed(2)}x',
SettingsListSwitch( value: settingsStore.messageScale,
title: 'Sync message delay and stream latency', min: 0.5,
value: settingsStore.autoSyncChatDelay, max: 2.0,
onChanged: (newValue) => settingsStore.autoSyncChatDelay = newValue, divisions: 6,
), onChanged: (newValue) => settingsStore.messageScale = newValue,
if (!settingsStore.autoSyncChatDelay) ),
SettingsListSlider( SettingsListSlider(
title: 'Message delay', title: 'Message spacing',
trailing: '${settingsStore.chatDelay.toInt()} seconds', trailing:
subtitle: '${settingsStore.messageSpacing.toStringAsFixed(0)}px',
'Adds a delay before each message is rendered in chat. ${Platform.isIOS ? '15 seconds is recommended for iOS.' : ''}', value: settingsStore.messageSpacing,
value: settingsStore.chatDelay, max: 30.0,
max: 30.0, divisions: 15,
divisions: 30, onChanged: (newValue) =>
onChanged: (newValue) => settingsStore.chatDelay = newValue, settingsStore.messageSpacing = newValue,
), ),
const SectionHeader('Alerts'), SettingsListSlider(
SettingsListSwitch( title: 'Font size',
title: 'Highlight first time chatters', trailing: settingsStore.fontSize.toInt().toString(),
value: settingsStore.highlightFirstTimeChatter, value: settingsStore.fontSize,
onChanged: (newValue) => min: 5,
settingsStore.highlightFirstTimeChatter = newValue, max: 20,
), divisions: 15,
SettingsListSwitch( onChanged: (newValue) => settingsStore.fontSize = newValue,
title: 'Show notices', ),
subtitle: const Text( const SectionHeader('Message appearance'),
'Shows notices such as subs and re-subs, announcements, and raids.', SettingsListSwitch(
), title: 'Show deleted messages',
value: settingsStore.showUserNotices, subtitle: const Text(
onChanged: (newValue) => settingsStore.showUserNotices = newValue, 'Restores the original message of deleted messages.',
), ),
const SectionHeader('Layout'), value: settingsStore.showDeletedMessages,
SettingsListSwitch( onChanged: (newValue) =>
title: 'Move emote menu button left', settingsStore.showDeletedMessages = newValue,
subtitle: const Text( ),
'Places the emote menu button on the left side to avoid accidental presses.', SettingsListSwitch(
), title: 'Show message dividers',
value: settingsStore.emoteMenuButtonOnLeft, value: settingsStore.showChatMessageDividers,
onChanged: (newValue) => onChanged: (newValue) =>
settingsStore.emoteMenuButtonOnLeft = newValue, settingsStore.showChatMessageDividers = newValue,
), ),
SettingsListSwitch( SettingsListSelect(
title: 'Persist chat tabs', title: 'Timestamps',
subtitle: const Text( selectedOption:
'Secondary chat tabs are remembered when switching channels.', timestampNames[settingsStore.timestampType.index],
), options: timestampNames,
value: settingsStore.persistChatTabs, onChanged: (newValue) => settingsStore.timestampType =
onChanged: (newValue) { TimestampType.values[timestampNames.indexOf(newValue)],
settingsStore.persistChatTabs = newValue; ),
if (!newValue) { const SectionHeader('Delay'),
settingsStore.secondaryTabs = []; SettingsListSwitch(
} title: 'Auto-sync chat delay',
}, value: settingsStore.autoSyncChatDelay,
), onChanged: (newValue) =>
const SectionHeader('Landscape mode'), settingsStore.autoSyncChatDelay = newValue,
SettingsListSwitch( ),
title: 'Move chat left', if (!settingsStore.autoSyncChatDelay)
value: settingsStore.landscapeChatLeftSide, SettingsListSlider(
onChanged: (newValue) => title: 'Chat delay',
settingsStore.landscapeChatLeftSide = newValue, trailing: '${settingsStore.chatDelay.toInt()} seconds',
), subtitle:
SettingsListSwitch( 'Adds a delay before each message is rendered in chat. ${Platform.isIOS ? '15 seconds is recommended for iOS.' : ''}',
title: 'Force vertical chat', value: settingsStore.chatDelay,
subtitle: const Text( max: 30.0,
'Intended for tablets and other larger displays.', divisions: 30,
), onChanged: (newValue) =>
value: settingsStore.landscapeForceVerticalChat, settingsStore.chatDelay = newValue,
onChanged: (newValue) => ),
settingsStore.landscapeForceVerticalChat = newValue, const SectionHeader('Alerts'),
), SettingsListSwitch(
SettingsListSelect( title: 'Highlight first-time chatters',
title: 'Fill notch side', value: settingsStore.highlightFirstTimeChatter,
subtitle: onChanged: (newValue) =>
'Overrides and fills the available space in devices with a display notch.', settingsStore.highlightFirstTimeChatter = newValue,
selectedOption: ),
landscapeCutoutNames[settingsStore.landscapeCutout.index], SettingsListSwitch(
options: landscapeCutoutNames, title: 'Show notices',
onChanged: (newValue) => settingsStore.landscapeCutout = subtitle: const Text(
LandscapeCutoutType.values[landscapeCutoutNames.indexOf( 'Shows notices such as subs, announcements, and raids.',
newValue, ),
)], value: settingsStore.showUserNotices,
), onChanged: (newValue) =>
SettingsListSlider( settingsStore.showUserNotices = newValue,
title: 'Chat overlay opacity', ),
trailing: const SectionHeader('Layout'),
'${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%', SettingsListSwitch(
subtitle: title: 'Emote menu on left',
'Sets the opacity (transparency) of the overlay chat in fullscreen mode.', subtitle: const Text(
value: settingsStore.fullScreenChatOverlayOpacity, 'Places the emote menu button on the left side.',
divisions: 10, ),
onChanged: (newValue) => value: settingsStore.emoteMenuButtonOnLeft,
settingsStore.fullScreenChatOverlayOpacity = newValue, onChanged: (newValue) =>
), settingsStore.emoteMenuButtonOnLeft = newValue,
const SectionHeader('Muted keywords'), ),
SettingsMutedWords(settingsStore: settingsStore), SettingsListSwitch(
SettingsListSwitch( title: 'Remember chat tabs',
title: 'Match whole words', subtitle: const Text(
subtitle: const Text( 'Secondary chat tabs are kept when switching channels.',
'Only matches whole words instead of partial matches.', ),
), value: settingsStore.persistChatTabs,
value: settingsStore.matchWholeWord, onChanged: (newValue) {
onChanged: (newValue) => settingsStore.matchWholeWord = newValue, settingsStore.persistChatTabs = newValue;
), if (!newValue) {
const SectionHeader('Autocomplete'), settingsStore.secondaryTabs = [];
SettingsListSwitch( }
title: 'Show autocomplete bar', },
subtitle: const Text( ),
'Shows a bar containing matching emotes and mentions while typing.', const SectionHeader('Landscape'),
), SettingsListSwitch(
value: settingsStore.autocomplete, title: 'Chat on left side',
onChanged: (newValue) => settingsStore.autocomplete = newValue, value: settingsStore.landscapeChatLeftSide,
), onChanged: (newValue) =>
const SectionHeader('Emotes and badges'), settingsStore.landscapeChatLeftSide = newValue,
SettingsListSwitch( ),
title: 'Show Twitch emotes', SettingsListSwitch(
value: settingsStore.showTwitchEmotes, title: 'Force vertical chat',
onChanged: (newValue) => settingsStore.showTwitchEmotes = newValue, subtitle: const Text(
), 'Intended for tablets and larger displays.',
SettingsListSwitch( ),
title: 'Show Twitch badges', value: settingsStore.landscapeForceVerticalChat,
value: settingsStore.showTwitchBadges, onChanged: (newValue) =>
onChanged: (newValue) => settingsStore.showTwitchBadges = newValue, settingsStore.landscapeForceVerticalChat = newValue,
), ),
SettingsListSwitch( SettingsListSelect(
title: 'Show 7TV emotes', title: 'Notch fill',
value: settingsStore.show7TVEmotes, subtitle:
onChanged: (newValue) => settingsStore.show7TVEmotes = newValue, 'Fills the display cutout area on the selected side.',
), selectedOption:
SettingsListSwitch( landscapeCutoutNames[settingsStore.landscapeCutout.index],
title: 'Show BTTV emotes', options: landscapeCutoutNames,
value: settingsStore.showBTTVEmotes, onChanged: (newValue) => settingsStore.landscapeCutout =
onChanged: (newValue) => settingsStore.showBTTVEmotes = newValue, LandscapeCutoutType.values[landscapeCutoutNames.indexOf(
), newValue,
SettingsListSwitch( )],
title: 'Show BTTV badges', ),
value: settingsStore.showBTTVBadges, SettingsListSlider(
onChanged: (newValue) => settingsStore.showBTTVBadges = newValue, title: 'Overlay chat opacity',
), trailing:
SettingsListSwitch( '${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%',
title: 'Show FFZ emotes', subtitle:
value: settingsStore.showFFZEmotes, 'Opacity of the chat overlay in fullscreen mode.',
onChanged: (newValue) => settingsStore.showFFZEmotes = newValue, value: settingsStore.fullScreenChatOverlayOpacity,
), divisions: 10,
SettingsListSwitch( onChanged: (newValue) =>
title: 'Show FFZ badges', settingsStore.fullScreenChatOverlayOpacity = newValue,
value: settingsStore.showFFZBadges, ),
onChanged: (newValue) => settingsStore.showFFZBadges = newValue, const SectionHeader('Filtering'),
), SettingsMutedWords(settingsStore: settingsStore),
const SectionHeader('Recent messages'), SettingsListSwitch(
SettingsListSwitch( title: 'Match whole words',
title: 'Show historical recent messages', subtitle: const Text(
subtitle: Text.rich( 'Only matches whole words instead of partial matches.',
TextSpan( ),
text: value: settingsStore.matchWholeWord,
'Loads historical recent messages in chat through a third-party API service at ', onChanged: (newValue) =>
children: [ settingsStore.matchWholeWord = newValue,
TextSpan( ),
text: 'https://recent-messages.robotty.de/', const SectionHeader('Emotes and badges'),
style: TextStyle( SettingsListSwitch(
color: context.colorScheme.primary, title: 'Twitch emotes',
decoration: TextDecoration.underline, value: settingsStore.showTwitchEmotes,
decorationColor: context.colorScheme.primary, onChanged: (newValue) =>
), settingsStore.showTwitchEmotes = newValue,
recognizer: TapGestureRecognizer() ),
..onTap = () => launchUrl( SettingsListSwitch(
Uri.parse('https://recent-messages.robotty.de/'), title: 'Twitch badges',
mode: settingsStore.launchUrlExternal value: settingsStore.showTwitchBadges,
? LaunchMode.externalApplication onChanged: (newValue) =>
: LaunchMode.inAppBrowserView, settingsStore.showTwitchBadges = newValue,
), ),
), SettingsListSwitch(
], title: '7TV emotes',
), value: settingsStore.show7TVEmotes,
), onChanged: (newValue) =>
value: settingsStore.showRecentMessages, settingsStore.show7TVEmotes = newValue,
onChanged: (newValue) => ),
settingsStore.showRecentMessages = 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), AccentColorSetting(settingsStore: settingsStore),
const SectionHeader('Stream card'), const SectionHeader('Stream card'),
SettingsListSwitch( SettingsListSwitch(
title: 'Use large stream card', title: 'Large stream cards',
value: settingsStore.largeStreamCard, value: settingsStore.largeStreamCard,
onChanged: (newValue) => settingsStore.largeStreamCard = newValue, onChanged: (newValue) => settingsStore.largeStreamCard = newValue,
), ),
SettingsListSwitch( SettingsListSwitch(
title: 'Show thumbnail', title: 'Show thumbnails',
value: settingsStore.showThumbnails, value: settingsStore.showThumbnails,
onChanged: (newValue) => settingsStore.showThumbnails = newValue, 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/constants.dart';
import 'package:frosty/main.dart'; import 'package:frosty/main.dart';
import 'package:frosty/screens/settings/stores/user_store.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:frosty/widgets/frosty_dialog.dart';
import 'package:mobx/mobx.dart'; import 'package:mobx/mobx.dart';
import 'package:webview_flutter/webview_flutter.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. /// The shared_preferences key for the user token.
static const _userTokenKey = '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. /// The Twitch API service for making requests.
final TwitchApi twitchApi; final TwitchApi twitchApi;
@ -49,6 +53,10 @@ abstract class AuthBase with Store {
@readonly @readonly
String? _token; 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. /// Whether the user is logged in or not.
@readonly @readonly
var _isLoggedIn = false; 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". // 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. // 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') { if (request.url == 'https://www.twitch.tv/?no-reload=true') {
_extractGqlToken();
if (routeAfter != null) { if (routeAfter != null) {
navigatorKey.currentState?.pop(); navigatorKey.currentState?.pop();
navigatorKey.currentState?.push( navigatorKey.currentState?.push(
@ -211,12 +220,27 @@ abstract class AuthBase with Store {
AuthBase({required this.twitchApi}) : user = UserStore(twitchApi: twitchApi); 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');
}
}
/// Initialize by retrieving a token if it does not already exist. /// Initialize by retrieving a token if it does not already exist.
@action @action
Future<void> init() async { Future<void> init() async {
try { try {
// Read and set the currently stored user token, if any. // Read and set the currently stored user token, if any.
_token = await _storage.read(key: _userTokenKey); _token = await _storage.read(key: _userTokenKey);
_gqlToken = await _storage.read(key: _gqlTokenKey);
// If the token does not exist, get the default token. // If the token does not exist, get the default token.
// Otherwise, log in. // Otherwise, log in.
@ -293,9 +317,11 @@ abstract class AuthBase with Store {
Future<void> logout() async { Future<void> logout() async {
try { try {
_stopReconnectLoop(); _stopReconnectLoop();
// Delete the existing user token. // Delete the existing user token and GQL token.
await _storage.delete(key: _userTokenKey); await _storage.delete(key: _userTokenKey);
await _storage.delete(key: _gqlTokenKey);
_token = null; _token = null;
_gqlToken = null;
// Clear the user info. // Clear the user info.
user.dispose(); 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( late final _$_isLoggedInAtom = Atom(
name: 'AuthBase._isLoggedIn', name: 'AuthBase._isLoggedIn',
context: context, context: context,

View file

@ -65,9 +65,8 @@ abstract class _SettingsStoreBase with Store {
static const defaultShowVideo = true; static const defaultShowVideo = true;
static const defaultDefaultToHighestQuality = false; static const defaultDefaultToHighestQuality = false;
static const defaultUseTextureRendering = true; static const defaultUseTextureRendering = true;
static const defaultKeepScreenAwake = true; static const defaultUseNativePlayer = false;
// Overlay defaults
static const defaultShowOverlay = true; static const defaultShowOverlay = true;
static const defaultToggleableOverlay = false; static const defaultToggleableOverlay = false;
static const defaultShowLatency = false; static const defaultShowLatency = false;
@ -85,9 +84,9 @@ abstract class _SettingsStoreBase with Store {
@observable @observable
var useTextureRendering = defaultUseTextureRendering; var useTextureRendering = defaultUseTextureRendering;
@JsonKey(defaultValue: defaultKeepScreenAwake) @JsonKey(defaultValue: defaultUseNativePlayer)
@observable @observable
var keepScreenAwake = defaultKeepScreenAwake; var useNativePlayer = defaultUseNativePlayer;
// Overlay options // Overlay options
@JsonKey(defaultValue: defaultShowOverlay) @JsonKey(defaultValue: defaultShowOverlay)
@ -107,7 +106,7 @@ abstract class _SettingsStoreBase with Store {
showVideo = defaultShowVideo; showVideo = defaultShowVideo;
defaultToHighestQuality = defaultDefaultToHighestQuality; defaultToHighestQuality = defaultDefaultToHighestQuality;
useTextureRendering = defaultUseTextureRendering; useTextureRendering = defaultUseTextureRendering;
keepScreenAwake = defaultKeepScreenAwake; useNativePlayer = defaultUseNativePlayer;
showOverlay = defaultShowOverlay; showOverlay = defaultShowOverlay;
toggleableOverlay = defaultToggleableOverlay; toggleableOverlay = defaultToggleableOverlay;
@ -115,44 +114,25 @@ abstract class _SettingsStoreBase with Store {
} }
// * Chat Settings // * Chat Settings
// Message sizing defaults
// -- Messages --
// Sizing defaults
static const defaultBadgeScale = 1.0; static const defaultBadgeScale = 1.0;
static const defaultEmoteScale = 1.0; static const defaultEmoteScale = 1.0;
static const defaultMessageScale = 1.0; static const defaultMessageScale = 1.0;
static const defaultMessageSpacing = 8.0; static const defaultMessageSpacing = 8.0;
static const defaultFontSize = 12.0; static const defaultFontSize = 12.0;
// Message appearance defaults // Appearance defaults
static const defaultShowDeletedMessages = false; static const defaultShowDeletedMessages = false;
static const defaultShowChatMessageDividers = false; static const defaultShowChatMessageDividers = false;
static const defaultTimestampType = TimestampType.disabled; static const defaultTimestampType = TimestampType.disabled;
// Delay defaults
static const defaultAutoSyncChatDelay = false;
static const defaultChatDelay = 0.0;
// Alert defaults // Alert defaults
static const defaultHighlightFirstTimeChatter = true; static const defaultHighlightFirstTimeChatter = true;
static const defaultShowUserNotices = true; static const defaultShowUserNotices = true;
// Layout defaults // -- Emotes & Badges --
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
static const defaultShowTwitchEmotes = true; static const defaultShowTwitchEmotes = true;
static const defaultShowTwitchBadges = true; static const defaultShowTwitchBadges = true;
static const defaultShow7TVEmotes = true; static const defaultShow7TVEmotes = true;
@ -161,14 +141,26 @@ abstract class _SettingsStoreBase with Store {
static const defaultShowFFZEmotes = true; static const defaultShowFFZEmotes = true;
static const defaultShowFFZBadges = true; static const defaultShowFFZBadges = true;
// Recent messages defaults // -- Layout --
static const defaultShowRecentMessages = false; static const defaultEmoteMenuButtonOnLeft = false;
// Chat tabs defaults
static const defaultPersistChatTabs = true; static const defaultPersistChatTabs = true;
static const defaultSecondaryTabs = <PersistedChatTab>[]; 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) @JsonKey(defaultValue: defaultBadgeScale)
@observable @observable
var badgeScale = defaultBadgeScale; var badgeScale = defaultBadgeScale;
@ -189,7 +181,6 @@ abstract class _SettingsStoreBase with Store {
@observable @observable
var fontSize = defaultFontSize; var fontSize = defaultFontSize;
// Message appearance options
@JsonKey(defaultValue: defaultShowDeletedMessages) @JsonKey(defaultValue: defaultShowDeletedMessages)
@observable @observable
var showDeletedMessages = defaultShowDeletedMessages; var showDeletedMessages = defaultShowDeletedMessages;
@ -205,24 +196,6 @@ abstract class _SettingsStoreBase with Store {
@observable @observable
var timestampType = defaultTimestampType; var timestampType = defaultTimestampType;
// Delay options
@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;
// Alert options
@JsonKey(defaultValue: defaultHighlightFirstTimeChatter) @JsonKey(defaultValue: defaultHighlightFirstTimeChatter)
@observable @observable
var highlightFirstTimeChatter = defaultHighlightFirstTimeChatter; var highlightFirstTimeChatter = defaultHighlightFirstTimeChatter;
@ -231,38 +204,7 @@ abstract class _SettingsStoreBase with Store {
@observable @observable
var showUserNotices = defaultShowUserNotices; var showUserNotices = defaultShowUserNotices;
// Layout options // Emotes & Badges 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
@JsonKey(defaultValue: defaultShowTwitchEmotes) @JsonKey(defaultValue: defaultShowTwitchEmotes)
@observable @observable
var showTwitchEmotes = defaultShowTwitchEmotes; var showTwitchEmotes = defaultShowTwitchEmotes;
@ -291,12 +233,11 @@ abstract class _SettingsStoreBase with Store {
@observable @observable
var showFFZBadges = defaultShowFFZBadges; var showFFZBadges = defaultShowFFZBadges;
// Recent messages // Layout options
@JsonKey(defaultValue: defaultShowRecentMessages) @JsonKey(defaultValue: defaultEmoteMenuButtonOnLeft)
@observable @observable
var showRecentMessages = defaultShowRecentMessages; var emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft;
// Chat tabs
@JsonKey(defaultValue: defaultPersistChatTabs) @JsonKey(defaultValue: defaultPersistChatTabs)
@observable @observable
var persistChatTabs = defaultPersistChatTabs; var persistChatTabs = defaultPersistChatTabs;
@ -305,6 +246,55 @@ abstract class _SettingsStoreBase with Store {
@observable @observable
var secondaryTabs = defaultSecondaryTabs; 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) @JsonKey(defaultValue: defaultMutedWords)
@observable @observable
List<String> mutedWords = defaultMutedWords; List<String> mutedWords = defaultMutedWords;
@ -315,36 +305,19 @@ abstract class _SettingsStoreBase with Store {
@action @action
void resetChatSettings() { void resetChatSettings() {
// Messages
badgeScale = defaultBadgeScale; badgeScale = defaultBadgeScale;
emoteScale = defaultEmoteScale; emoteScale = defaultEmoteScale;
messageScale = defaultMessageScale; messageScale = defaultMessageScale;
messageSpacing = defaultMessageSpacing; messageSpacing = defaultMessageSpacing;
fontSize = defaultFontSize; fontSize = defaultFontSize;
showDeletedMessages = defaultShowDeletedMessages; showDeletedMessages = defaultShowDeletedMessages;
showChatMessageDividers = defaultShowChatMessageDividers; showChatMessageDividers = defaultShowChatMessageDividers;
timestampType = defaultTimestampType; timestampType = defaultTimestampType;
autoSyncChatDelay = defaultAutoSyncChatDelay;
chatDelay = defaultChatDelay;
syncedChatDelay = defaultChatDelay;
highlightFirstTimeChatter = defaultHighlightFirstTimeChatter; highlightFirstTimeChatter = defaultHighlightFirstTimeChatter;
showUserNotices = defaultShowUserNotices; showUserNotices = defaultShowUserNotices;
emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft; // Emotes & Badges
landscapeChatLeftSide = defaultLandscapeChatLeftSide;
landscapeForceVerticalChat = defaultLandscapeForceVerticalChat;
landscapeCutout = defaultLandscapeCutout;
chatWidth = defaultChatWidth;
fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity;
mutedWords = defaultMutedWords;
matchWholeWord = defaultMatchWholeWord;
autocomplete = defaultAutocomplete;
showTwitchEmotes = defaultShowTwitchEmotes; showTwitchEmotes = defaultShowTwitchEmotes;
showTwitchBadges = defaultShowTwitchBadges; showTwitchBadges = defaultShowTwitchBadges;
show7TVEmotes = defaultShow7TVEmotes; show7TVEmotes = defaultShow7TVEmotes;
@ -353,10 +326,25 @@ abstract class _SettingsStoreBase with Store {
showFFZEmotes = defaultShowFFZEmotes; showFFZEmotes = defaultShowFFZEmotes;
showFFZBadges = defaultShowFFZBadges; showFFZBadges = defaultShowFFZBadges;
showRecentMessages = defaultShowRecentMessages; // Layout
emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft;
persistChatTabs = defaultPersistChatTabs; persistChatTabs = defaultPersistChatTabs;
secondaryTabs = defaultSecondaryTabs; 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 // * Other settings

View file

@ -23,7 +23,7 @@ SettingsStore _$SettingsStoreFromJson(
..showVideo = json['showVideo'] as bool? ?? true ..showVideo = json['showVideo'] as bool? ?? true
..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false ..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false
..useTextureRendering = json['useTextureRendering'] as bool? ?? true ..useTextureRendering = json['useTextureRendering'] as bool? ?? true
..keepScreenAwake = json['keepScreenAwake'] as bool? ?? true ..useNativePlayer = json['useNativePlayer'] as bool? ?? false
..showOverlay = json['showOverlay'] as bool? ?? true ..showOverlay = json['showOverlay'] as bool? ?? true
..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false ..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false
..showLatency = json['showLatency'] as bool? ?? false ..showLatency = json['showLatency'] as bool? ?? false
@ -41,12 +41,23 @@ SettingsStore _$SettingsStoreFromJson(
unknownValue: TimestampType.disabled, unknownValue: TimestampType.disabled,
) ?? ) ??
TimestampType.disabled TimestampType.disabled
..autoSyncChatDelay = json['autoSyncChatDelay'] as bool? ?? false
..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0
..highlightFirstTimeChatter = ..highlightFirstTimeChatter =
json['highlightFirstTimeChatter'] as bool? ?? true json['highlightFirstTimeChatter'] as bool? ?? true
..showUserNotices = json['showUserNotices'] 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 ..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 ..landscapeChatLeftSide = json['landscapeChatLeftSide'] as bool? ?? false
..landscapeForceVerticalChat = ..landscapeForceVerticalChat =
json['landscapeForceVerticalChat'] as bool? ?? false json['landscapeForceVerticalChat'] as bool? ?? false
@ -59,21 +70,11 @@ SettingsStore _$SettingsStoreFromJson(
..chatWidth = (json['chatWidth'] as num?)?.toDouble() ?? 0.2 ..chatWidth = (json['chatWidth'] as num?)?.toDouble() ?? 0.2
..fullScreenChatOverlayOpacity = ..fullScreenChatOverlayOpacity =
(json['fullScreenChatOverlayOpacity'] as num?)?.toDouble() ?? 0.5 (json['fullScreenChatOverlayOpacity'] as num?)?.toDouble() ?? 0.5
..keepScreenAwake = json['keepScreenAwake'] as bool? ?? true
..autocomplete = json['autocomplete'] 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 ..showRecentMessages = json['showRecentMessages'] as bool? ?? false
..persistChatTabs = json['persistChatTabs'] as bool? ?? true ..autoSyncChatDelay = json['autoSyncChatDelay'] as bool? ?? false
..secondaryTabs = ..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0
(json['secondaryTabs'] as List<dynamic>?)
?.map((e) => PersistedChatTab.fromJson(e as Map<String, dynamic>))
.toList() ??
[]
..mutedWords = ..mutedWords =
(json['mutedWords'] as List<dynamic>?) (json['mutedWords'] as List<dynamic>?)
?.map((e) => e as String) ?.map((e) => e as String)
@ -101,7 +102,7 @@ Map<String, dynamic> _$SettingsStoreToJson(
'showVideo': instance.showVideo, 'showVideo': instance.showVideo,
'defaultToHighestQuality': instance.defaultToHighestQuality, 'defaultToHighestQuality': instance.defaultToHighestQuality,
'useTextureRendering': instance.useTextureRendering, 'useTextureRendering': instance.useTextureRendering,
'keepScreenAwake': instance.keepScreenAwake, 'useNativePlayer': instance.useNativePlayer,
'showOverlay': instance.showOverlay, 'showOverlay': instance.showOverlay,
'toggleableOverlay': instance.toggleableOverlay, 'toggleableOverlay': instance.toggleableOverlay,
'showLatency': instance.showLatency, 'showLatency': instance.showLatency,
@ -113,17 +114,8 @@ Map<String, dynamic> _$SettingsStoreToJson(
'showDeletedMessages': instance.showDeletedMessages, 'showDeletedMessages': instance.showDeletedMessages,
'showChatMessageDividers': instance.showChatMessageDividers, 'showChatMessageDividers': instance.showChatMessageDividers,
'timestampType': _$TimestampTypeEnumMap[instance.timestampType]!, 'timestampType': _$TimestampTypeEnumMap[instance.timestampType]!,
'autoSyncChatDelay': instance.autoSyncChatDelay,
'chatDelay': instance.chatDelay,
'highlightFirstTimeChatter': instance.highlightFirstTimeChatter, 'highlightFirstTimeChatter': instance.highlightFirstTimeChatter,
'showUserNotices': instance.showUserNotices, '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, 'showTwitchEmotes': instance.showTwitchEmotes,
'showTwitchBadges': instance.showTwitchBadges, 'showTwitchBadges': instance.showTwitchBadges,
'show7TVEmotes': instance.show7TVEmotes, 'show7TVEmotes': instance.show7TVEmotes,
@ -131,9 +123,19 @@ Map<String, dynamic> _$SettingsStoreToJson(
'showBTTVBadges': instance.showBTTVBadges, 'showBTTVBadges': instance.showBTTVBadges,
'showFFZEmotes': instance.showFFZEmotes, 'showFFZEmotes': instance.showFFZEmotes,
'showFFZBadges': instance.showFFZBadges, 'showFFZBadges': instance.showFFZBadges,
'showRecentMessages': instance.showRecentMessages, 'emoteMenuButtonOnLeft': instance.emoteMenuButtonOnLeft,
'persistChatTabs': instance.persistChatTabs, 'persistChatTabs': instance.persistChatTabs,
'secondaryTabs': instance.secondaryTabs, '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, 'mutedWords': instance.mutedWords,
'matchWholeWord': instance.matchWholeWord, 'matchWholeWord': instance.matchWholeWord,
'shareCrashLogsAndAnalytics': instance.shareCrashLogsAndAnalytics, 'shareCrashLogsAndAnalytics': instance.shareCrashLogsAndAnalytics,
@ -325,21 +327,21 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
}); });
} }
late final _$keepScreenAwakeAtom = Atom( late final _$useNativePlayerAtom = Atom(
name: '_SettingsStoreBase.keepScreenAwake', name: '_SettingsStoreBase.useNativePlayer',
context: context, context: context,
); );
@override @override
bool get keepScreenAwake { bool get useNativePlayer {
_$keepScreenAwakeAtom.reportRead(); _$useNativePlayerAtom.reportRead();
return super.keepScreenAwake; return super.useNativePlayer;
} }
@override @override
set keepScreenAwake(bool value) { set useNativePlayer(bool value) {
_$keepScreenAwakeAtom.reportWrite(value, super.keepScreenAwake, () { _$useNativePlayerAtom.reportWrite(value, super.useNativePlayer, () {
super.keepScreenAwake = value; super.useNativePlayer = value;
}); });
} }
@ -545,60 +547,6 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
}); });
} }
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 _$highlightFirstTimeChatterAtom = Atom( late final _$highlightFirstTimeChatterAtom = Atom(
name: '_SettingsStoreBase.highlightFirstTimeChatter', name: '_SettingsStoreBase.highlightFirstTimeChatter',
context: context, context: context,
@ -639,148 +587,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( late final _$showTwitchEmotesAtom = Atom(
name: '_SettingsStoreBase.showTwitchEmotes', name: '_SettingsStoreBase.showTwitchEmotes',
context: context, context: context,
@ -907,22 +713,26 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
}); });
} }
late final _$showRecentMessagesAtom = Atom( late final _$emoteMenuButtonOnLeftAtom = Atom(
name: '_SettingsStoreBase.showRecentMessages', name: '_SettingsStoreBase.emoteMenuButtonOnLeft',
context: context, context: context,
); );
@override @override
bool get showRecentMessages { bool get emoteMenuButtonOnLeft {
_$showRecentMessagesAtom.reportRead(); _$emoteMenuButtonOnLeftAtom.reportRead();
return super.showRecentMessages; return super.emoteMenuButtonOnLeft;
} }
@override @override
set showRecentMessages(bool value) { set emoteMenuButtonOnLeft(bool value) {
_$showRecentMessagesAtom.reportWrite(value, super.showRecentMessages, () { _$emoteMenuButtonOnLeftAtom.reportWrite(
super.showRecentMessages = value; value,
}); super.emoteMenuButtonOnLeft,
() {
super.emoteMenuButtonOnLeft = value;
},
);
} }
late final _$persistChatTabsAtom = Atom( late final _$persistChatTabsAtom = Atom(
@ -961,6 +771,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( late final _$mutedWordsAtom = Atom(
name: '_SettingsStoreBase.mutedWords', name: '_SettingsStoreBase.mutedWords',
context: context, context: context,
@ -1165,7 +1185,7 @@ launchUrlExternal: ${launchUrlExternal},
showVideo: ${showVideo}, showVideo: ${showVideo},
defaultToHighestQuality: ${defaultToHighestQuality}, defaultToHighestQuality: ${defaultToHighestQuality},
useTextureRendering: ${useTextureRendering}, useTextureRendering: ${useTextureRendering},
keepScreenAwake: ${keepScreenAwake}, useNativePlayer: ${useNativePlayer},
showOverlay: ${showOverlay}, showOverlay: ${showOverlay},
toggleableOverlay: ${toggleableOverlay}, toggleableOverlay: ${toggleableOverlay},
showLatency: ${showLatency}, showLatency: ${showLatency},
@ -1177,18 +1197,8 @@ fontSize: ${fontSize},
showDeletedMessages: ${showDeletedMessages}, showDeletedMessages: ${showDeletedMessages},
showChatMessageDividers: ${showChatMessageDividers}, showChatMessageDividers: ${showChatMessageDividers},
timestampType: ${timestampType}, timestampType: ${timestampType},
autoSyncChatDelay: ${autoSyncChatDelay},
chatDelay: ${chatDelay},
syncedChatDelay: ${syncedChatDelay},
highlightFirstTimeChatter: ${highlightFirstTimeChatter}, highlightFirstTimeChatter: ${highlightFirstTimeChatter},
showUserNotices: ${showUserNotices}, showUserNotices: ${showUserNotices},
emoteMenuButtonOnLeft: ${emoteMenuButtonOnLeft},
landscapeChatLeftSide: ${landscapeChatLeftSide},
landscapeForceVerticalChat: ${landscapeForceVerticalChat},
landscapeCutout: ${landscapeCutout},
chatWidth: ${chatWidth},
fullScreenChatOverlayOpacity: ${fullScreenChatOverlayOpacity},
autocomplete: ${autocomplete},
showTwitchEmotes: ${showTwitchEmotes}, showTwitchEmotes: ${showTwitchEmotes},
showTwitchBadges: ${showTwitchBadges}, showTwitchBadges: ${showTwitchBadges},
show7TVEmotes: ${show7TVEmotes}, show7TVEmotes: ${show7TVEmotes},
@ -1196,9 +1206,20 @@ showBTTVEmotes: ${showBTTVEmotes},
showBTTVBadges: ${showBTTVBadges}, showBTTVBadges: ${showBTTVBadges},
showFFZEmotes: ${showFFZEmotes}, showFFZEmotes: ${showFFZEmotes},
showFFZBadges: ${showFFZBadges}, showFFZBadges: ${showFFZBadges},
showRecentMessages: ${showRecentMessages}, emoteMenuButtonOnLeft: ${emoteMenuButtonOnLeft},
persistChatTabs: ${persistChatTabs}, persistChatTabs: ${persistChatTabs},
secondaryTabs: ${secondaryTabs}, 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}, mutedWords: ${mutedWords},
matchWholeWord: ${matchWholeWord}, matchWholeWord: ${matchWholeWord},
shareCrashLogsAndAnalytics: ${shareCrashLogsAndAnalytics}, shareCrashLogsAndAnalytics: ${shareCrashLogsAndAnalytics},

View file

@ -20,10 +20,20 @@ class VideoSettings extends StatelessWidget {
children: [ children: [
const SectionHeader('Player', isFirst: true), const SectionHeader('Player', isFirst: true),
SettingsListSwitch( SettingsListSwitch(
title: 'Enable video', title: 'Show video player',
value: settingsStore.showVideo, value: settingsStore.showVideo,
onChanged: (newValue) => settingsStore.showVideo = newValue, 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()) if (!Platform.isIOS || isIPad())
SettingsListSwitch( SettingsListSwitch(
title: 'Default to highest quality', title: 'Default to highest quality',
@ -41,14 +51,6 @@ class VideoSettings extends StatelessWidget {
onChanged: (newValue) => onChanged: (newValue) =>
settingsStore.useTextureRendering = 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'), const SectionHeader('Overlay'),
SettingsListSwitch( SettingsListSwitch(
title: 'Use custom video overlay', title: 'Use custom video overlay',
@ -59,12 +61,13 @@ class VideoSettings extends StatelessWidget {
onChanged: (newValue) => settingsStore.showOverlay = newValue, onChanged: (newValue) => settingsStore.showOverlay = newValue,
), ),
SettingsListSwitch( SettingsListSwitch(
title: 'Long-press player to toggle overlay', title: 'Toggle overlay on long-press',
subtitle: const Text( 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, value: settingsStore.toggleableOverlay,
onChanged: (newValue) => settingsStore.toggleableOverlay = newValue, onChanged: (newValue) =>
settingsStore.toggleableOverlay = newValue,
), ),
SettingsListSwitch( SettingsListSwitch(
title: 'Show latency', 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

@ -196,6 +196,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( navigationBarTheme: NavigationBarThemeData(
elevation: 0, elevation: 0,
backgroundColor: backgroundColor, 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/ffz_api.dart';
import 'package:frosty/apis/seventv_api.dart'; import 'package:frosty/apis/seventv_api.dart';
import 'package:frosty/apis/twitch_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/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/stores/global_assets_store.dart'; import 'package:frosty/stores/global_assets_store.dart';
@ -71,6 +72,9 @@ extension ContextExtensions on BuildContext {
/// Gets TwitchApi from provider /// Gets TwitchApi from provider
TwitchApi get twitchApi => read<TwitchApi>(); TwitchApi get twitchApi => read<TwitchApi>();
/// Gets TwitchGqlApi from provider
TwitchGqlApi get twitchGqlApi => read<TwitchGqlApi>();
/// Gets AuthStore from provider /// Gets AuthStore from provider
AuthStore get authStore => read<AuthStore>(); AuthStore get authStore => read<AuthStore>();

View file

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

View file

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

View file

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

View file

@ -97,6 +97,15 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.0" version: "2.13.0"
better_native_video_player:
dependency: "direct main"
description:
path: "."
ref: frosty-patches
resolved-ref: "92b2c025b368225c4ee75db567f561be78c5ce45"
url: "https://github.com/tommyxchow/flutter-native-video-player.git"
source: git
version: "0.4.11"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@ -181,10 +190,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.0"
checked_yaml: checked_yaml:
dependency: transitive dependency: transitive
description: description:
@ -313,6 +322,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
dismissible_page:
dependency: transitive
description:
name: dismissible_page
sha256: "5b2316f770fe83583f770df1f6505cb19102081c5971979806e77f2e507a9958"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
extended_text_field: extended_text_field:
dependency: "direct main" dependency: "direct main"
description: description:
@ -449,6 +466,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
floating:
dependency: transitive
description:
name: floating
sha256: e51ce1dbcab3ea83da0ad1b07f2091b99c0e8680184c780a000567f41234d454
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@ -772,18 +797,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.19" version: "0.12.17"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.0" version: "0.11.1"
meta: meta:
dependency: transitive dependency: transitive
description: description:
@ -1242,10 +1267,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.10" version: "0.7.7"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:

View file

@ -71,6 +71,10 @@ dependencies:
app_links: ^7.0.0 app_links: ^7.0.0
extended_text_field: ^16.0.2 extended_text_field: ^16.0.2
native_device_orientation: ^2.1.0 native_device_orientation: ^2.1.0
better_native_video_player:
git:
url: https://github.com/tommyxchow/flutter-native-video-player.git
ref: frosty-patches
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View file

@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/models/emotes.dart'; import 'package:frosty/models/emotes.dart';
import 'package:frosty/models/video.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart'; import 'package:http_mock_adapter/http_mock_adapter.dart';
import '../fixtures/api_responses.dart'; import '../fixtures/api_responses.dart';
@ -389,4 +390,63 @@ void main() {
expect(emotes[2].type, EmoteType.twitchChannel); expect(emotes[2].type, EmoteType.twitchChannel);
}); });
}); });
group('getVideos', () {
test('returns videos for a user', () async {
dioAdapter.onGet(
'https://api.twitch.tv/helix/videos',
(server) => server.reply(200, {
'data': [
{
'id': '12345',
'stream_id': '67890',
'user_id': '111',
'user_login': 'testuser',
'user_name': 'TestUser',
'title': 'Test VOD',
'created_at': '2024-01-15T10:30:00Z',
'published_at': '2024-01-15T10:30:00Z',
'duration': '3h26m15s',
'thumbnail_url': 'https://cdn/thumb.jpg',
'view_count': 500,
},
],
'pagination': {'cursor': 'next_page'},
}),
queryParameters: {
'user_id': '111',
'first': '20',
'type': 'archive',
},
);
final videos = await api.getVideos(userId: '111');
expect(videos.data, hasLength(1));
expect(videos.data.first.id, '12345');
expect(videos.data.first.title, 'Test VOD');
expect(videos.data.first.duration, '3h26m15s');
expect(videos.pagination['cursor'], 'next_page');
});
test('passes cursor for pagination', () async {
dioAdapter.onGet(
'https://api.twitch.tv/helix/videos',
(server) => server.reply(200, {
'data': <Map<String, dynamic>>[],
'pagination': <String, String>{},
}),
queryParameters: {
'user_id': '111',
'first': '20',
'type': 'archive',
'after': 'page2',
},
);
final videos = await api.getVideos(userId: '111', cursor: 'page2');
expect(videos.data, isEmpty);
});
});
} }

View file

@ -0,0 +1,208 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:frosty/constants.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart';
void main() {
late Dio dio;
late DioAdapter dioAdapter;
late TwitchGqlApi api;
setUp(() {
dio = Dio(BaseOptions());
dioAdapter = DioAdapter(dio: dio);
api = TwitchGqlApi(dio);
});
group('getVideoComments', () {
test('fetches comments by content offset', () async {
dioAdapter.onPost(
'https://gql.twitch.tv/gql',
(server) => server.reply(200, {
'data': {
'video': {
'comments': {
'edges': [
{
'node': {
'id': 'c1',
'contentOffsetSeconds': 10,
'commenter': {
'id': 'u1',
'displayName': 'User1',
'login': 'user1',
},
'message': {
'userColor': '#FF0000',
'userBadges': <Map<String, dynamic>>[],
'fragments': [
{'text': 'hello', 'emote': null},
],
},
},
'cursor': 'cursor-1',
},
],
'pageInfo': {'hasNextPage': true},
},
},
},
}),
data: {
'operationName': 'VideoCommentsByOffsetOrCursor',
'variables': {
'videoID': 'v123',
'contentOffsetSeconds': 0,
},
'extensions': {
'persistedQuery': {
'version': 1,
'sha256Hash':
'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a',
},
},
},
headers: {'Client-ID': twitchGqlMobileClientId},
);
final response = await api.getVideoComments(
videoId: 'v123',
contentOffsetSeconds: 0,
);
expect(response.comments, hasLength(1));
expect(response.comments.first.id, 'c1');
expect(response.comments.first.commenterLogin, 'user1');
expect(response.hasNextPage, isTrue);
expect(response.cursor, 'cursor-1');
});
test('fetches comments by cursor', () async {
dioAdapter.onPost(
'https://gql.twitch.tv/gql',
(server) => server.reply(200, {
'data': {
'video': {
'comments': {
'edges': [
{
'node': {
'id': 'c2',
'contentOffsetSeconds': 20,
'commenter': {
'id': 'u2',
'displayName': 'User2',
'login': 'user2',
},
'message': {
'userColor': null,
'userBadges': <Map<String, dynamic>>[],
'fragments': [
{'text': 'world', 'emote': null},
],
},
},
'cursor': 'cursor-2',
},
],
'pageInfo': {'hasNextPage': false},
},
},
},
}),
data: {
'operationName': 'VideoCommentsByOffsetOrCursor',
'variables': {
'videoID': 'v123',
'cursor': 'cursor-1',
},
'extensions': {
'persistedQuery': {
'version': 1,
'sha256Hash':
'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a',
},
},
},
headers: {'Client-ID': twitchGqlMobileClientId},
);
final response = await api.getVideoComments(
videoId: 'v123',
cursor: 'cursor-1',
);
expect(response.comments, hasLength(1));
expect(response.comments.first.id, 'c2');
expect(response.hasNextPage, isFalse);
});
test('handles null video (VOD not found)', () async {
dioAdapter.onPost(
'https://gql.twitch.tv/gql',
(server) => server.reply(200, {
'data': {'video': null},
}),
data: {
'operationName': 'VideoCommentsByOffsetOrCursor',
'variables': {
'videoID': 'nonexistent',
'contentOffsetSeconds': 0,
},
'extensions': {
'persistedQuery': {
'version': 1,
'sha256Hash':
'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a',
},
},
},
headers: {'Client-ID': twitchGqlMobileClientId},
);
final response = await api.getVideoComments(
videoId: 'nonexistent',
contentOffsetSeconds: 0,
);
expect(response.comments, isEmpty);
expect(response.hasNextPage, isFalse);
});
test('throws on GQL errors', () async {
dioAdapter.onPost(
'https://gql.twitch.tv/gql',
(server) => server.reply(200, {
'errors': [
{'message': 'Something went wrong'},
],
'data': null,
}),
data: {
'operationName': 'VideoCommentsByOffsetOrCursor',
'variables': {
'videoID': 'v123',
'contentOffsetSeconds': 0,
},
'extensions': {
'persistedQuery': {
'version': 1,
'sha256Hash':
'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a',
},
},
},
headers: {'Client-ID': twitchGqlMobileClientId},
);
expect(
() => api.getVideoComments(
videoId: 'v123',
contentOffsetSeconds: 0,
),
throwsA(isA<Exception>()),
);
});
});
}

View file

@ -0,0 +1,216 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/models/gql_chat_comment.dart';
import 'package:frosty/models/irc.dart';
void main() {
group('GqlChatComment', () {
test('fromGqlNode parses a complete comment', () {
final node = {
'id': 'comment-1',
'contentOffsetSeconds': 120,
'commenter': {
'id': 'user-123',
'displayName': 'TestUser',
'login': 'testuser',
},
'message': {
'userColor': '#FF0000',
'userBadges': [
{'setID': 'subscriber', 'version': '12'},
],
'fragments': [
{'text': 'Hello world ', 'emote': null},
{
'text': 'Kappa',
'emote': {'emoteID': '25'},
},
],
},
};
final comment = GqlChatComment.fromGqlNode(node);
expect(comment.id, 'comment-1');
expect(comment.contentOffsetSeconds, 120);
expect(comment.commenterLogin, 'testuser');
expect(comment.commenterDisplayName, 'TestUser');
expect(comment.commenterId, 'user-123');
expect(comment.userColor, '#FF0000');
expect(comment.userBadges.length, 1);
expect(comment.userBadges.first.setId, 'subscriber');
expect(comment.userBadges.first.version, '12');
expect(comment.messageFragments.length, 2);
expect(comment.messageFragments[0].text, 'Hello world ');
expect(comment.messageFragments[0].emote, isNull);
expect(comment.messageFragments[1].text, 'Kappa');
expect(comment.messageFragments[1].emote!.emoteId, '25');
});
test('fromGqlNode handles deleted commenter (null)', () {
final node = {
'id': 'comment-2',
'contentOffsetSeconds': 60,
'commenter': null,
'message': {
'userColor': null,
'userBadges': <Map<String, dynamic>>[],
'fragments': [
{'text': 'deleted message', 'emote': null},
],
},
};
final comment = GqlChatComment.fromGqlNode(node);
expect(comment.commenterLogin, isNull);
expect(comment.commenterDisplayName, isNull);
expect(comment.commenterId, isNull);
expect(comment.userColor, isNull);
expect(comment.userBadges, isEmpty);
});
});
group('GqlChatResponse', () {
test('fromJson parses a valid response', () {
final json = {
'data': {
'video': {
'comments': {
'edges': [
{
'node': {
'id': 'c1',
'contentOffsetSeconds': 10,
'commenter': {
'id': 'u1',
'displayName': 'User1',
'login': 'user1',
},
'message': {
'userColor': '#00FF00',
'userBadges': <Map<String, dynamic>>[],
'fragments': [
{'text': 'hello', 'emote': null},
],
},
},
'cursor': 'cursor-abc',
},
],
'pageInfo': {'hasNextPage': true},
},
},
},
};
final response = GqlChatResponse.fromJson(json);
expect(response.comments.length, 1);
expect(response.comments.first.id, 'c1');
expect(response.hasNextPage, isTrue);
expect(response.cursor, 'cursor-abc');
});
test('fromJson throws on GQL errors', () {
final json = {
'errors': [
{'message': 'Something went wrong'},
],
'data': null,
};
expect(
() => GqlChatResponse.fromJson(json),
throwsA(isA<Exception>()),
);
});
test('fromJson handles null video (VOD not found)', () {
final json = {
'data': {'video': null},
};
final response = GqlChatResponse.fromJson(json);
expect(response.comments, isEmpty);
expect(response.hasNextPage, isFalse);
});
});
group('IRCMessage.fromGqlComment', () {
test('converts comment to IRCMessage with correct tags', () {
final comment = GqlChatComment(
id: 'msg-1',
contentOffsetSeconds: 300,
commenterLogin: 'testuser',
commenterDisplayName: 'TestUser',
commenterId: 'user-123',
userColor: '#FF0000',
userBadges: [
const GqlChatBadge(setId: 'subscriber', version: '3'),
const GqlChatBadge(setId: 'moderator', version: '1'),
],
messageFragments: [
const GqlChatFragment(text: 'Hello world'),
],
);
final irc = IRCMessage.fromGqlComment(comment);
expect(irc.command, Command.privateMessage);
expect(irc.user, 'testuser');
expect(irc.tags['display-name'], 'TestUser');
expect(irc.tags['user-id'], 'user-123');
expect(irc.tags['id'], 'msg-1');
expect(irc.tags['color'], '#FF0000');
expect(irc.tags['badges'], 'subscriber/3,moderator/1');
expect(irc.tags['tmi-sent-ts'], '300000');
expect(irc.message, 'Hello world');
expect(irc.split, ['Hello', 'world']);
expect(irc.localEmotes, isNull);
});
test('populates localEmotes for Twitch emotes', () {
final comment = GqlChatComment(
id: 'msg-2',
contentOffsetSeconds: 10,
commenterLogin: 'user',
commenterDisplayName: 'User',
commenterId: 'u1',
userBadges: [],
messageFragments: [
const GqlChatFragment(text: 'Hey '),
const GqlChatFragment(
text: 'Kappa',
emote: GqlChatEmote(emoteId: '25'),
),
const GqlChatFragment(text: ' nice'),
],
);
final irc = IRCMessage.fromGqlComment(comment);
expect(irc.message, 'Hey Kappa nice');
expect(irc.localEmotes, isNotNull);
expect(irc.localEmotes!['Kappa'], isNotNull);
expect(irc.localEmotes!['Kappa']!.name, 'Kappa');
expect(irc.localEmotes!['Kappa']!.url, contains('25'));
});
test('handles deleted commenter', () {
final comment = GqlChatComment(
id: 'msg-3',
contentOffsetSeconds: 0,
userBadges: [],
messageFragments: [
const GqlChatFragment(text: 'deleted message'),
],
);
final irc = IRCMessage.fromGqlComment(comment);
expect(irc.tags['display-name'], 'Deleted User');
expect(irc.user, isNull);
expect(irc.tags['user-id'], '');
});
});
}

128
test/models/video_test.dart Normal file
View file

@ -0,0 +1,128 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/models/video.dart';
void main() {
group('VideoTwitch', () {
test('fromJson parses all fields correctly', () {
final json = {
'id': '12345',
'stream_id': '67890',
'user_id': '111',
'user_login': 'testuser',
'user_name': 'TestUser',
'title': 'Test Stream VOD',
'created_at': '2024-01-15T10:30:00Z',
'published_at': '2024-01-15T10:30:00Z',
'duration': '3h26m15s',
'thumbnail_url':
'https://static-cdn.jtvnw.net/cf_vods/abc/%{width}x%{height}.jpg',
'view_count': 12345,
};
final video = VideoTwitch.fromJson(json);
expect(video.id, '12345');
expect(video.streamId, '67890');
expect(video.userId, '111');
expect(video.userLogin, 'testuser');
expect(video.userName, 'TestUser');
expect(video.title, 'Test Stream VOD');
expect(video.createdAt, '2024-01-15T10:30:00Z');
expect(video.publishedAt, '2024-01-15T10:30:00Z');
expect(video.duration, '3h26m15s');
expect(video.thumbnailUrl, contains('%{width}'));
expect(video.viewCount, 12345);
});
test('fromJson handles null streamId', () {
final json = {
'id': '12345',
'stream_id': null,
'user_id': '111',
'user_login': 'testuser',
'user_name': 'TestUser',
'title': 'Test',
'created_at': '2024-01-15T10:30:00Z',
'published_at': '2024-01-15T10:30:00Z',
'duration': '1h0m0s',
'thumbnail_url': '',
'view_count': 0,
};
final video = VideoTwitch.fromJson(json);
expect(video.streamId, isNull);
});
});
group('VideosTwitch', () {
test('fromJson parses data list and pagination', () {
final json = {
'data': [
{
'id': '1',
'stream_id': null,
'user_id': '111',
'user_login': 'user',
'user_name': 'User',
'title': 'Title',
'created_at': '2024-01-01T00:00:00Z',
'published_at': '2024-01-01T00:00:00Z',
'duration': '1h0m0s',
'thumbnail_url': '',
'view_count': 100,
},
],
'pagination': {'cursor': 'abc123'},
};
final videos = VideosTwitch.fromJson(json);
expect(videos.data.length, 1);
expect(videos.data.first.id, '1');
expect(videos.pagination['cursor'], 'abc123');
});
test('fromJson handles empty data', () {
final json = {
'data': <Map<String, dynamic>>[],
'pagination': <String, String>{},
};
final videos = VideosTwitch.fromJson(json);
expect(videos.data, isEmpty);
});
});
group('parseTwitchDuration', () {
test('parses hours, minutes, and seconds', () {
expect(
parseTwitchDuration('3h26m15s'),
const Duration(hours: 3, minutes: 26, seconds: 15),
);
});
test('parses minutes and seconds only', () {
expect(
parseTwitchDuration('45m30s'),
const Duration(minutes: 45, seconds: 30),
);
});
test('parses seconds only', () {
expect(
parseTwitchDuration('30s'),
const Duration(seconds: 30),
);
});
test('parses hours and minutes only', () {
expect(
parseTwitchDuration('2h10m'),
const Duration(hours: 2, minutes: 10),
);
});
test('returns Duration.zero for empty string', () {
expect(parseTwitchDuration(''), Duration.zero);
});
});
}