diff --git a/android/app/src/main/kotlin/com/tommychow/frosty/CookieExtractorPlugin.kt b/android/app/src/main/kotlin/com/tommychow/frosty/CookieExtractorPlugin.kt new file mode 100644 index 00000000..055101d0 --- /dev/null +++ b/android/app/src/main/kotlin/com/tommychow/frosty/CookieExtractorPlugin.kt @@ -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) + } +} diff --git a/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt b/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt index a7791025..30e59dc7 100644 --- a/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt +++ b/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt @@ -1,6 +1,11 @@ package com.tommychow.frosty import cl.puntito.simple_pip_mode.PipCallbackHelperActivityWrapper +import io.flutter.embedding.engine.FlutterEngine class MainActivity: PipCallbackHelperActivityWrapper() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + flutterEngine.plugins.add(CookieExtractorPlugin()) + } } diff --git a/ios/Podfile.lock b/ios/Podfile.lock index a571234b..f0d96492 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3,6 +3,8 @@ PODS: - Flutter - app_links (7.0.0): - Flutter + - better_native_video_player (0.4.11): + - Flutter - device_info_plus (0.0.1): - Flutter - Firebase/CoreOnly (12.9.0): @@ -192,6 +194,7 @@ PODS: DEPENDENCIES: - advanced_in_app_review (from `.symlinks/plugins/advanced_in_app_review/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`) - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`) @@ -237,6 +240,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/advanced_in_app_review/ios" app_links: :path: ".symlinks/plugins/app_links/ios" + better_native_video_player: + :path: ".symlinks/plugins/better_native_video_player/ios" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" firebase_analytics: @@ -273,6 +278,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: advanced_in_app_review: fe8fd50b8670cb3dbe67e4c26ca643f7aea8dbb8 app_links: 6d01271b3907b0ee7325c5297c75d697c4226c4d + better_native_video_player: 88a6b9bf169428352c05b6ebec085c5263603282 device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 Firebase: 065f2bb395062046623036d8e6dc857bc2521d56 firebase_analytics: b5c909bdc321fab0f9ddf8712f5cef79057e09fa diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 3c0a4184..88ab511b 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E751BC99755AAC391DC0B29F /* Pods_Runner.framework */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 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 */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 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 = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieExtractorPlugin.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 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 = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; @@ -106,6 +108,7 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */, ); @@ -304,6 +307,7 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + F1C00CE01A2B3C4D00E5F6A8 /* CookieExtractorPlugin.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 8be1cecd..b85295f0 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -8,6 +8,7 @@ import UIKit didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) + CookieExtractorPlugin.register(with: self.registrar(forPlugin: "CookieExtractorPlugin")!) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } } diff --git a/ios/Runner/CookieExtractorPlugin.swift b/ios/Runner/CookieExtractorPlugin.swift new file mode 100644 index 00000000..e39a4acc --- /dev/null +++ b/ios/Runner/CookieExtractorPlugin.swift @@ -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) + } + } +} diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 8aa7eded..6052fdf9 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -54,5 +54,9 @@ UIStatusBarHidden + UIBackgroundModes + + audio + diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index 1028c216..b03f2f3c 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -10,6 +10,7 @@ import 'package:frosty/models/followed_channel.dart'; import 'package:frosty/models/shared_chat_session.dart'; import 'package:frosty/models/stream.dart'; import 'package:frosty/models/user.dart'; +import 'package:frosty/models/video.dart'; /// The Twitch service for making API calls. class TwitchApi extends BaseApiClient { @@ -205,6 +206,23 @@ class TwitchApi extends BaseApiClient { return FollowedChannels.fromJson(data); } + /// Returns a [VideosTwitch] object containing past broadcasts for the given [userId]. + Future getVideos({ + required String userId, + String? cursor, + }) async { + final queryParams = { + 'user_id': userId, + 'first': '20', + 'type': 'archive', + }; + if (cursor != null) queryParams['after'] = cursor; + + final data = await get('/videos', queryParameters: queryParams); + + return VideosTwitch.fromJson(data); + } + /// Returns a [StreamsTwitch] object that contains the list of streams under the given game/category ID. Future getStreamsUnderCategory({ required String gameId, diff --git a/lib/apis/twitch_gql_api.dart b/lib/apis/twitch_gql_api.dart new file mode 100644 index 00000000..24a9b83f --- /dev/null +++ b/lib/apis/twitch_gql_api.dart @@ -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 getPlaybackAccessToken({ + required String login, + String? authToken, + }) async { + final headers = { + '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( + '/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 getVideoComments({ + required String videoId, + int? contentOffsetSeconds, + String? cursor, + }) async { + final headers = { + 'Client-ID': twitchGqlMobileClientId, + }; + + final variables = { + '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( + '/gql', + data: body, + headers: headers, + ); + + return GqlChatResponse.fromJson(response); + } +} diff --git a/lib/apis/unauthorized_interceptor.dart b/lib/apis/unauthorized_interceptor.dart index 5f3f26cc..bd983fc4 100644 --- a/lib/apis/unauthorized_interceptor.dart +++ b/lib/apis/unauthorized_interceptor.dart @@ -14,8 +14,9 @@ class UnauthorizedInterceptor extends Interceptor { @override void onError(DioException err, ErrorInterceptorHandler handler) { - // Check if this is a 401 Unauthorized error - if (err.response?.statusCode == 401) { + // Check if this is a 401 Unauthorized error on a Twitch Helix/OAuth request + if (err.response?.statusCode == 401 && + _isTwitchHelixOrOAuth(err.requestOptions.uri)) { // For token validation requests, let the error propagate so validateToken can handle it if (err.requestOptions.uri.path.endsWith('/validate')) { handler.next(err); @@ -89,4 +90,10 @@ class UnauthorizedInterceptor extends Interceptor { } static bool _isDialogShowing = false; + + bool _isTwitchHelixOrOAuth(Uri uri) { + final url = uri.toString(); + return url.startsWith('https://api.twitch.tv/helix') || + url.startsWith('https://id.twitch.tv/oauth2'); + } } diff --git a/lib/constants.dart b/lib/constants.dart index 2edaddba..a81d1943 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -3,6 +3,15 @@ import 'package:flutter/material.dart'; /// Twitch API client ID. const clientId = String.fromEnvironment('CLIENT_ID'); +/// Twitch GQL client ID (web player, used for playback access tokens). +const twitchGqlClientId = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; + +/// SharedPreferences key for the 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. const secret = String.fromEnvironment('SECRET'); diff --git a/lib/main.dart b/lib/main.dart index cc428c84..795374b6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,6 +17,7 @@ import 'package:frosty/apis/ffz_api.dart'; import 'package:frosty/apis/seventv_api.dart'; import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/apis/twitch_auth_interceptor.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/apis/unauthorized_interceptor.dart'; import 'package:frosty/cache_manager.dart'; import 'package:frosty/firebase_options.dart'; @@ -104,6 +105,7 @@ void main() async { final bttvApiService = BTTVApi(dioClient); final ffzApiService = FFZApi(dioClient); final sevenTVApiService = SevenTVApi(dioClient); + final twitchGqlApiService = TwitchGqlApi(dioClient); // Create global assets store (shared cache for global emotes/badges) final globalAssetsStore = GlobalAssetsStore( @@ -137,6 +139,7 @@ void main() async { Provider.value(value: bttvApiService), Provider.value(value: ffzApiService), Provider.value(value: sevenTVApiService), + Provider.value(value: twitchGqlApiService), Provider.value(value: globalAssetsStore), ], child: MyApp(firstRun: firstRun), diff --git a/lib/models/gql_chat_comment.dart b/lib/models/gql_chat_comment.dart new file mode 100644 index 00000000..bdbb628a --- /dev/null +++ b/lib/models/gql_chat_comment.dart @@ -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 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 json) { + return GqlChatFragment( + text: json['text'] as String?, + emote: json['emote'] != null + ? GqlChatEmote.fromJson(json['emote'] as Map) + : null, + ); + } +} + +class GqlChatBadge { + final String setId; + final String version; + + const GqlChatBadge({required this.setId, required this.version}); + + factory GqlChatBadge.fromJson(Map 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 userBadges; + final List 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 node) { + final commenter = node['commenter'] as Map?; + final message = node['message'] as Map; + + 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)) + .toList(), + messageFragments: (message['fragments'] as List) + .map((f) => GqlChatFragment.fromJson(f as Map)) + .toList(), + ); + } +} + +class GqlChatResponse { + final List comments; + final bool hasNextPage; + final String? cursor; + + const GqlChatResponse({ + required this.comments, + required this.hasNextPage, + this.cursor, + }); + + factory GqlChatResponse.fromJson(Map 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)['message'] as String? + : 'Unknown GQL error'; + throw Exception('GQL error: $message'); + } + + final video = json['data']?['video'] as Map?; + if (video == null) { + return const GqlChatResponse( + comments: [], + hasNextPage: false, + ); + } + + final commentsData = video['comments'] as Map; + final edges = commentsData['edges'] as List; + final pageInfo = commentsData['pageInfo'] as Map; + + final comments = edges + .map((edge) => GqlChatComment.fromGqlNode( + (edge as Map)['node'] as Map, + )) + .toList(); + + final cursor = edges.isNotEmpty + ? (edges.last as Map)['cursor'] as String? + : null; + + return GqlChatResponse( + comments: comments, + hasNextPage: pageInfo['hasNextPage'] as bool, + cursor: cursor, + ); + } +} diff --git a/lib/models/irc.dart b/lib/models/irc.dart index 0cb0a6bf..cb214177 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -8,6 +8,7 @@ import 'package:frosty/cache_manager.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/models/badges.dart'; import 'package:frosty/models/emotes.dart'; +import 'package:frosty/models/gql_chat_comment.dart'; import 'package:frosty/models/user.dart'; import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; @@ -176,7 +177,9 @@ class IRCMessage { size: badgeSize, color: Theme.of(context).iconTheme.color?.withValues(alpha: 0.5) ?? - Colors.grey.withValues(alpha: 0.5), + Theme.of(context).colorScheme.onSurfaceVariant.withValues( + alpha: 0.5, + ), ), ), ), @@ -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 = { + '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 = {}; + 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({ required String message, VoidCallback? actionCallback, diff --git a/lib/models/playback_access_token.dart b/lib/models/playback_access_token.dart new file mode 100644 index 00000000..5c926a56 --- /dev/null +++ b/lib/models/playback_access_token.dart @@ -0,0 +1,24 @@ +class PlaybackAccessToken { + final String value; + final String signature; + + const PlaybackAccessToken({ + required this.value, + required this.signature, + }); + + factory PlaybackAccessToken.fromGqlResponse(Map json) { + final data = + (json['data'] as Map?)?['streamPlaybackAccessToken'] + as Map?; + if (data == null) { + throw const FormatException( + 'streamPlaybackAccessToken missing from GQL response', + ); + } + return PlaybackAccessToken( + value: data['value'] as String, + signature: data['signature'] as String, + ); + } +} diff --git a/lib/models/video.dart b/lib/models/video.dart new file mode 100644 index 00000000..26bdc882 --- /dev/null +++ b/lib/models/video.dart @@ -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 json) => + _$VideoTwitchFromJson(json); +} + +@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake) +class VideosTwitch { + final List data; + final Map pagination; + + const VideosTwitch(this.data, this.pagination); + + factory VideosTwitch.fromJson(Map 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); +} diff --git a/lib/models/video.g.dart b/lib/models/video.g.dart new file mode 100644 index 00000000..202fa376 --- /dev/null +++ b/lib/models/video.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'video.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +VideoTwitch _$VideoTwitchFromJson(Map 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 json) => VideosTwitch( + (json['data'] as List) + .map((e) => VideoTwitch.fromJson(e as Map)) + .toList(), + Map.from(json['pagination'] as Map), +); diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index 3d24bb26..8218c6ad 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -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_tabs_store.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart'; +import 'package:frosty/screens/channel/video/native_video.dart'; +import 'package:frosty/screens/channel/video/native_video_store.dart'; import 'package:frosty/screens/channel/video/stream_info_bar.dart'; import 'package:frosty/screens/channel/video/video.dart'; import 'package:frosty/screens/channel/video/video_overlay.dart'; +import 'package:frosty/screens/channel/video/video_player_interface.dart'; import 'package:frosty/screens/channel/video/video_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/theme.dart'; @@ -75,13 +78,23 @@ class _VideoChatState extends State primaryDisplayName: widget.userName, ); - late final VideoStore _videoStore = VideoStore( - userLogin: widget.userLogin, - userId: widget.userId, - twitchApi: context.twitchApi, - authStore: context.authStore, - settingsStore: context.settingsStore, - ); + late final VideoPlayerInterface _videoStore = + context.settingsStore.useNativePlayer + ? NativeVideoStore( + userLogin: widget.userLogin, + userId: widget.userId, + twitchApi: context.twitchApi, + twitchGqlApi: context.twitchGqlApi, + authStore: context.authStore, + settingsStore: context.settingsStore, + ) + : VideoStore( + userLogin: widget.userLogin, + userId: widget.userId, + twitchApi: context.twitchApi, + authStore: context.authStore, + settingsStore: context.settingsStore, + ); @override void initState() { @@ -297,7 +310,12 @@ class _VideoChatState extends State final player = GestureDetector( onLongPress: _videoStore.handleToggleOverlay, - child: Video(key: _videoKey, videoStore: _videoStore), + child: _videoStore is NativeVideoStore + ? NativeVideo(nativeVideoStore: _videoStore) + : Video( + key: _videoKey, + videoStore: _videoStore as VideoStore, + ), ); final overlay = GestureDetector( @@ -318,6 +336,10 @@ class _VideoChatState extends State }, child: Observer( builder: (_) { + if (_videoStore.loading) { + return const SizedBox.shrink(); + } + final videoOverlay = VideoOverlay( videoStore: _videoStore, chatStore: _chatStore, @@ -349,6 +371,28 @@ class _VideoChatState extends State return Stack( children: [ 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, ], ); @@ -683,7 +727,7 @@ class _VideoChatState extends State if (settingsStore.showVideo) ...[ AspectRatio( aspectRatio: 16 / 9, - child: Container(), // Placeholder for video space + child: SizedBox(), ), ], Expanded(child: chat), diff --git a/lib/screens/channel/chat/chat_render_context.dart b/lib/screens/channel/chat/chat_render_context.dart new file mode 100644 index 00000000..533dc668 --- /dev/null +++ b/lib/screens/channel/chat/chat_render_context.dart @@ -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 get revealedMessageIds; + void revealMessage(String id); + void updateNotification(String message); +} diff --git a/lib/screens/channel/chat/details/chat_details.dart b/lib/screens/channel/chat/details/chat_details.dart index 987e15bf..e8563d78 100644 --- a/lib/screens/channel/chat/details/chat_details.dart +++ b/lib/screens/channel/chat/details/chat_details.dart @@ -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_users_list.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/utils.dart'; import 'package:frosty/utils/modal_bottom_sheet.dart'; @@ -371,6 +372,20 @@ class _ChatDetailsState extends State { ), ), ), + 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( leading: const Icon(Icons.add_comment_rounded), title: const Text('Add chat'), diff --git a/lib/screens/channel/chat/details/chat_modes.dart b/lib/screens/channel/chat/details/chat_modes.dart index b43ec476..403381dc 100644 --- a/lib/screens/channel/chat/details/chat_modes.dart +++ b/lib/screens/channel/chat/details/chat_modes.dart @@ -171,7 +171,7 @@ class ChatModes extends StatelessWidget { style: TextStyle( color: Theme.of( context, - ).colorScheme.onSurfaceVariant, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.7), ), ), ], diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index e8b5d607..3526c955 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -9,6 +9,7 @@ import 'package:frosty/models/badges.dart'; import 'package:frosty/models/emotes.dart'; import 'package:frosty/models/events.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/stores/chat_assets_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. 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. static const _messageLimit = 5000; @@ -99,9 +100,11 @@ abstract class ChatStoreBase with Store { final _messagesToRemove = (_messageLimit * 0.2).toInt(); /// The provided auth store to determine login status, get the token, and use the headers for requests. + @override final AuthStore auth; /// The provided setting store to account for any user-defined behaviors. + @override final SettingsStore settings; /// 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; /// The channel's ID for API requests. + @override final String channelId; /// The channel's display name to show on widgets. @@ -153,6 +157,7 @@ abstract class ChatStoreBase with Store { final ChatDetailsStore chatDetailsStore; /// The assets store responsible for badges, emotes, and the emote menu. + @override final ChatAssetsStore assetsStore; /// 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>[]; /// The set of message IDs that have been revealed by the user (for deleted messages). + @override final revealedMessageIds = ObservableSet(); + @override @action void revealMessage(String 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]. + @override @action void updateNotification(String notificationMessage) { // Cancel the previous notification to prevent the notification from phasing in and out diff --git a/lib/screens/channel/chat/widgets/chat_message.dart b/lib/screens/channel/chat/widgets/chat_message.dart index d7645750..bdad638d 100644 --- a/lib/screens/channel/chat/widgets/chat_message.dart +++ b/lib/screens/channel/chat/widgets/chat_message.dart @@ -6,6 +6,7 @@ import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/models/irc.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/widgets/chat_user_modal.dart'; import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart'; @@ -15,7 +16,7 @@ import 'package:provider/provider.dart'; class ChatMessage extends StatelessWidget { final IRCMessage ircMessage; - final ChatStore chatStore; + final ChatRenderContext chatStore; final bool isModal; final bool showReplyHeader; final bool isInReplyThread; @@ -51,6 +52,9 @@ class ChatMessage extends StatelessWidget { // Ignore if the message is a recent message in the modal bottom sheet. if (isModal) return; + // User modal requires live ChatStore (recent messages, blocking, etc.). + if (chatStore is! ChatStore) return; + // Ignore if long-pressing own username. if (ircMessage.user == null || ircMessage.user == chatStore.auth.user.details?.login) { @@ -61,7 +65,7 @@ class ChatMessage extends StatelessWidget { isScrollControlled: true, context: context, builder: (context) => ChatUserModal( - chatStore: chatStore, + chatStore: chatStore as ChatStore, username: ircMessage.user!, userId: ircMessage.tags['user-id']!, 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. if (isModal) return; + // User modal requires live ChatStore. + if (chatStore is! ChatStore) return; + final twitchApi = context.read(); twitchApi.getUser(userLogin: nickname).then((user) { if (context.mounted) { @@ -80,7 +87,7 @@ class ChatMessage extends StatelessWidget { isScrollControlled: true, context: context, builder: (context) => ChatUserModal( - chatStore: chatStore, + chatStore: chatStore as ChatStore, username: user.login, userId: user.id, displayName: user.displayName, @@ -91,7 +98,7 @@ class ChatMessage extends StatelessWidget { } /// The store to use for notifications and emote menu in merged mode. - ChatStore get _effectiveInputStore => inputChatStore ?? chatStore; + ChatRenderContext get _effectiveInputStore => inputChatStore ?? chatStore; Future copyMessage() async { await Clipboard.setData(ClipboardData(text: ircMessage.message ?? '')); @@ -144,53 +151,58 @@ class ChatMessage extends StatelessWidget { leading: const Icon(Icons.copy), title: const Text('Copy message'), ), - ListTile( - onTap: () async { - await copyMessage(); + if (chatStore is ChatStore) ...[ + ListTile( + onTap: () async { + await copyMessage(); + final liveChatStore = chatStore as ChatStore; - final effectiveChatDelay = chatStore.settings.effectiveChatDelay; + final effectiveChatDelay = + liveChatStore.settings.effectiveChatDelay; - final hasChatDelay = - chatStore.settings.showVideo && effectiveChatDelay > 0; + final hasChatDelay = + liveChatStore.settings.showVideo && effectiveChatDelay > 0; - if (hasChatDelay) { - _effectiveInputStore.updateNotification( - 'Chatting is disabled due to message delay (${effectiveChatDelay.toInt()}s)', - ); + if (hasChatDelay) { + _effectiveInputStore.updateNotification( + '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) { 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 - // targets the correct channel's composer. - onActivateSourceTab?.call(); - - // Paste the copied message into the text controller - chatStore.textController.text = ircMessage.message ?? ''; - chatStore.safeRequestFocus(); - if (context.mounted) { + liveChatStore.replyingToMessage = ircMessage; + liveChatStore.safeRequestFocus(); Navigator.pop(context); - } - }, - leading: const Icon(Icons.content_paste), - 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'), - ), + }, + leading: const Icon(Icons.reply), + title: const Text('Reply to message'), + ), + ], ], ), ); @@ -255,14 +267,14 @@ class ChatMessage extends StatelessWidget { color: messageHeaderTextColor, ); messageHeader = GestureDetector( - onTap: isModal + onTap: isModal || chatStore is! ChatStore ? null : () => showModalBottomSheetWithProperFocus( context: context, builder: (context) { return ReplyThread( selectedMessage: ircMessage, - chatStore: chatStore, + chatStore: chatStore as ChatStore, ); }, ), @@ -482,10 +494,14 @@ class ChatMessage extends StatelessWidget { messageHeaderIcon = Icon( Icons.campaign_rounded, size: messageHeaderIconSize, + color: messageHeaderTextColor, ); - messageHeader = const Text( + messageHeader = Text( 'Announcement', - style: TextStyle(fontWeight: messageHeaderFontWeight), + style: TextStyle( + fontWeight: messageHeaderFontWeight, + color: messageHeaderTextColor, + ), ); } diff --git a/lib/screens/channel/chat/widgets/reply_thread.dart b/lib/screens/channel/chat/widgets/reply_thread.dart index 734408b3..7dc83394 100644 --- a/lib/screens/channel/chat/widgets/reply_thread.dart +++ b/lib/screens/channel/chat/widgets/reply_thread.dart @@ -61,7 +61,7 @@ class ReplyThread extends StatelessWidget { padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), child: Text( '$replyName: $replyBody', - style: const TextStyle(fontWeight: FontWeight.w500), + style: const TextStyle(fontWeight: FontWeight.w600), ), ), Flexible( diff --git a/lib/screens/channel/video/native_video.dart b/lib/screens/channel/video/native_video.dart new file mode 100644 index 00000000..c8ddbcd0 --- /dev/null +++ b/lib/screens/channel/video/native_video.dart @@ -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, + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart new file mode 100644 index 00000000..837634f5 --- /dev/null +++ b/lib/screens/channel/video/native_video_store.dart @@ -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? _streamInfoRequest; + bool _overlayWasVisibleBeforePip = true; + var _userPaused = false; + + List _qualityObjects = []; + var _firstTimeSettingQuality = true; + int? _pendingQualityIndex; + var _disposed = false; + + final _pip = SimplePip(); + ReactionDisposer? _disposeAndroidAutoPipReaction; + + StreamSubscription? _pipSub; + StreamSubscription>? _qualitiesSub; + + @readonly + var _loading = true; + + @readonly + var _paused = true; + + @readonly + var _overlayVisible = true; + + @readonly + StreamTwitch? _streamInfo; + + @readonly + Channel? _offlineChannelInfo; + + @readonly + var _availableStreamQualities = []; + + @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 _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 = {}; + 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 _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 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 updateStreamQualities() async { + // Qualities are populated automatically via qualitiesStream. + } + + @override + @action + Future setStreamQuality(String quality) async { + final index = _availableStreamQualities.indexOf(quality); + if (index == -1) return; + await _setStreamQualityIndex(index); + } + + @action + Future _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 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 _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; + } +} diff --git a/lib/screens/channel/video/native_video_store.g.dart b/lib/screens/channel/video/native_video_store.g.dart new file mode 100644 index 00000000..a92378f0 --- /dev/null +++ b/lib/screens/channel/video/native_video_store.g.dart @@ -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 get availableStreamQualities { + _$_availableStreamQualitiesAtom.reportRead(); + return super._availableStreamQualities; + } + + @override + List get _availableStreamQualities => availableStreamQualities; + + @override + set _availableStreamQualities(List 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 _initPlayer() { + return _$_initPlayerAsyncAction.run(() => super._initPlayer()); + } + + late final _$handleRefreshAsyncAction = AsyncAction( + 'NativeVideoStoreBase.handleRefresh', + context: context, + ); + + @override + Future handleRefresh() { + return _$handleRefreshAsyncAction.run(() => super.handleRefresh()); + } + + late final _$updateStreamQualitiesAsyncAction = AsyncAction( + 'NativeVideoStoreBase.updateStreamQualities', + context: context, + ); + + @override + Future updateStreamQualities() { + return _$updateStreamQualitiesAsyncAction.run( + () => super.updateStreamQualities(), + ); + } + + late final _$setStreamQualityAsyncAction = AsyncAction( + 'NativeVideoStoreBase.setStreamQuality', + context: context, + ); + + @override + Future setStreamQuality(String quality) { + return _$setStreamQualityAsyncAction.run( + () => super.setStreamQuality(quality), + ); + } + + late final _$_setStreamQualityIndexAsyncAction = AsyncAction( + 'NativeVideoStoreBase._setStreamQualityIndex', + context: context, + ); + + @override + Future _setStreamQualityIndex(int index) { + return _$_setStreamQualityIndexAsyncAction.run( + () => super._setStreamQualityIndex(index), + ); + } + + late final _$updateStreamInfoAsyncAction = AsyncAction( + 'NativeVideoStoreBase.updateStreamInfo', + context: context, + ); + + @override + Future 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 ''' + + '''; + } +} diff --git a/lib/screens/channel/video/video_overlay.dart b/lib/screens/channel/video/video_overlay.dart index 62fb13b3..fff3f885 100644 --- a/lib/screens/channel/video/video_overlay.dart +++ b/lib/screens/channel/video/video_overlay.dart @@ -3,10 +3,12 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/constants.dart'; import 'package:frosty/screens/channel/chat/details/chat_users_list.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; +import 'package:frosty/screens/channel/video/native_video_store.dart'; import 'package:frosty/screens/channel/video/stream_info_bar.dart'; -import 'package:frosty/screens/channel/video/video_store.dart'; +import 'package:frosty/screens/channel/video/video_player_interface.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/theme.dart'; import 'package:frosty/utils.dart'; @@ -21,7 +23,7 @@ import 'package:shared_preferences/shared_preferences.dart'; /// Creates a widget containing controls which enable interactions with an underlying [Video] widget. class VideoOverlay extends StatelessWidget { - final VideoStore videoStore; + final VideoPlayerInterface videoStore; final ChatStore chatStore; final SettingsStore settingsStore; @@ -108,7 +110,7 @@ class VideoOverlay extends StatelessWidget { videoStore.setStreamQuality(quality); SharedPreferences.getInstance().then( (prefs) => prefs.setString( - 'last_stream_quality', + kLastStreamQualityKey, quality, ), ); @@ -259,7 +261,6 @@ class VideoOverlay extends StatelessWidget { displayName: chatStore.displayName, showUptime: false, showViewerCount: false, - showOfflineIndicator: false, textColor: surfaceColor, isOffline: true, isInSharedChatMode: @@ -344,7 +345,10 @@ class VideoOverlay extends StatelessWidget { if (videoStore.settingsStore.fullScreen && context.isLandscape) chatOverlayButton, - if (!Platform.isIOS || isIPad()) videoSettingsButton, + if (!Platform.isIOS || + isIPad() || + videoStore is NativeVideoStore) + videoSettingsButton, ], ), Center( diff --git a/lib/screens/channel/video/video_player_interface.dart b/lib/screens/channel/video/video_player_interface.dart new file mode 100644 index 00000000..1f2d24ba --- /dev/null +++ b/lib/screens/channel/video/video_player_interface.dart @@ -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 get availableStreamQualities; + String get streamQuality; + String? get latency; + + void handleVideoTap(); + void handlePausePlay(); + void handleToggleOverlay(); + Future handleRefresh(); + void requestPictureInPicture(); + void togglePictureInPicture(); + Future updateStreamQualities(); + Future setStreamQuality(String quality); + Future updateStreamInfo({bool forceUpdate}); + void handleAppResume(); + void dispose(); +} diff --git a/lib/screens/channel/video/video_store.dart b/lib/screens/channel/video/video_store.dart index 07889964..f3db3b97 100644 --- a/lib/screens/channel/video/video_store.dart +++ b/lib/screens/channel/video/video_store.dart @@ -5,8 +5,10 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/constants.dart'; import 'package:frosty/models/channel.dart'; import 'package:frosty/models/stream.dart'; +import 'package:frosty/screens/channel/video/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'; @@ -20,7 +22,7 @@ part 'video_store.g.dart'; class VideoStore = VideoStoreBase with _$VideoStore; -abstract class VideoStoreBase with Store { +abstract class VideoStoreBase with Store implements VideoPlayerInterface { final TwitchApi twitchApi; /// The userlogin of the current channel. @@ -31,6 +33,7 @@ abstract class VideoStoreBase with Store { final AuthStore authStore; + @override final SettingsStore settingsStore; /// The [SimplePip] instance used for initiating PiP on Android. @@ -86,7 +89,7 @@ abstract class VideoStoreBase with Store { return; } final prefs = await SharedPreferences.getInstance(); - final lastStreamQuality = prefs.getString('last_stream_quality'); + final lastStreamQuality = prefs.getString(kLastStreamQualityKey); if (lastStreamQuality == null) return; setStreamQuality(lastStreamQuality); } @@ -102,6 +105,7 @@ abstract class VideoStoreBase with Store { ..addJavaScriptChannel( 'VideoPlaying', onMessageReceived: (message) { + _loading = false; _paused = false; if (Platform.isAndroid) pip.setIsPlaying(true); }, @@ -169,6 +173,9 @@ abstract class VideoStoreBase with Store { /// Disposes the latency settings reaction. ReactionDisposer? _disposeLatencySettingsReaction; + @readonly + var _loading = true; + /// If the video is currently paused. /// /// Does not pause or play the video, only used for rendering state of the overlay. @@ -195,6 +202,7 @@ abstract class VideoStoreBase with Store { int _streamQualityIndex = 0; // The current stream quality string + @override String get streamQuality => _availableStreamQualities.elementAtOrNull(_streamQualityIndex) ?? 'Auto'; @@ -343,6 +351,7 @@ abstract class VideoStoreBase with Store { ); } + @override @action Future updateStreamQualities() async { try { @@ -382,6 +391,7 @@ abstract class VideoStoreBase with Store { } } + @override @action Future setStreamQuality(String newStreamQuality) async { final indexOfStreamQuality = _availableStreamQualities.indexOf( @@ -842,6 +852,7 @@ abstract class VideoStoreBase with Store { } /// Called whenever the video/overlay is tapped. + @override @action void handleVideoTap() { if (_isInPipMode) { @@ -918,6 +929,7 @@ abstract class VideoStoreBase with Store { } /// Handles app resume event for immediate stream info refresh in chat-only mode. + @override @action void handleAppResume() { // 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. /// Set [forceUpdate] to true to bypass the rate limiting check. + @override @action Future updateStreamInfo({bool forceUpdate = false}) async { if (_streamInfoRequest != null) { @@ -967,6 +980,7 @@ abstract class VideoStoreBase with Store { _offlineChannelInfo = null; } catch (e) { _overlayTimer?.cancel(); + _loading = false; _streamInfo = null; _paused = true; @@ -987,6 +1001,7 @@ abstract class VideoStoreBase with Store { /// Handles the toggle overlay options. /// /// The toggle overlay option allows switching between the custom and Twitch's overlay by long-pressing the overlay. + @override @action void handleToggleOverlay() { if (settingsStore.toggleableOverlay) { @@ -1004,9 +1019,11 @@ abstract class VideoStoreBase with Store { } /// Refreshes the stream webview and updates the stream info. + @override @action Future handleRefresh() async { HapticFeedback.lightImpact(); + _loading = true; _paused = true; _firstTimeSettingQuality = true; _isInPipMode = false; @@ -1028,6 +1045,7 @@ abstract class VideoStoreBase with Store { } /// Play or pause the video depending on the current state of [_paused]. + @override void handlePausePlay() { try { if (_paused) { @@ -1048,6 +1066,7 @@ abstract class VideoStoreBase with Store { /// /// On Android, this will utilize the native Android PiP API. /// On iOS, this will utilize the web picture-in-picture API. + @override void requestPictureInPicture() { try { if (Platform.isAndroid) { @@ -1067,6 +1086,7 @@ abstract class VideoStoreBase with Store { /// If not in PiP mode, enters PiP mode. /// If already in PiP mode on iOS, exits PiP mode. /// On Android, always enters PiP mode (no programmatic exit or state tracking). + @override @action void togglePictureInPicture() { try { @@ -1088,6 +1108,7 @@ abstract class VideoStoreBase with Store { } } + @override @action void dispose() { // Disable auto PiP when leaving so that we don't enter PiP on other screens. diff --git a/lib/screens/channel/video/video_store.g.dart b/lib/screens/channel/video/video_store.g.dart index 30c79efc..c12540e5 100644 --- a/lib/screens/channel/video/video_store.g.dart +++ b/lib/screens/channel/video/video_store.g.dart @@ -9,6 +9,26 @@ part of 'video_store.dart'; // ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers mixin _$VideoStore on VideoStoreBase, Store { + late final _$_loadingAtom = Atom( + name: 'VideoStoreBase._loading', + context: context, + ); + + bool get loading { + _$_loadingAtom.reportRead(); + return super._loading; + } + + @override + bool get _loading => loading; + + @override + set _loading(bool value) { + _$_loadingAtom.reportWrite(value, super._loading, () { + super._loading = value; + }); + } + late final _$_pausedAtom = Atom( name: 'VideoStoreBase._paused', context: context, diff --git a/lib/screens/channel/vod/stores/vod_chat_store.dart b/lib/screens/channel/vod/stores/vod_chat_store.dart new file mode 100644 index 00000000..f1c81b94 --- /dev/null +++ b/lib/screens/channel/vod/stores/vod_chat_store.dart @@ -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(); + + 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(); + + /// Timer used for dismissing the notification. + Timer? _notificationTimer; + + /// Fetched GQL comments waiting to be advanced based on playback position. + final _commentBuffer = Queue(); + + /// 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(); + + /// 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 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 init() async { + await Future.wait([ + assetsStore.init().then((_) => _loadAssets()), + _fetchComments(contentOffsetSeconds: 0), + ]); + _startMessageBufferTimer(); + _setupScrollListener(); + _isInitialized = true; + } + + Future _loadAssets() async { + await assetsStore.assetsFuture( + channelId: channelId, + headers: auth.headersTwitch, + onEmoteError: (error) { + debugPrint(error.toString()); + return []; + }, + onBadgeError: (error) { + debugPrint(error.toString()); + return []; + }, + 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 handleSeek(int offsetSeconds) async { + playbackPosition = offsetSeconds; + _commentBuffer.clear(); + messageBuffer.clear(); + _messages.clear(); + _lastRenderedOffset = -1; + _nextCursor = null; + await _fetchComments(contentOffsetSeconds: offsetSeconds); + } + + Future _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(); + } +} diff --git a/lib/screens/channel/vod/stores/vod_chat_store.g.dart b/lib/screens/channel/vod/stores/vod_chat_store.g.dart new file mode 100644 index 00000000..dbf39b1a --- /dev/null +++ b/lib/screens/channel/vod/stores/vod_chat_store.g.dart @@ -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>? _$renderMessagesComputed; + + @override + List get renderMessages => + (_$renderMessagesComputed ??= Computed>( + () => 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 get messages { + _$_messagesAtom.reportRead(); + return super._messages; + } + + @override + ObservableList get _messages => messages; + + @override + set _messages(ObservableList 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} + '''; + } +} diff --git a/lib/screens/channel/vod/stores/vod_list_store.dart b/lib/screens/channel/vod/stores/vod_list_store.dart new file mode 100644 index 00000000..997723ed --- /dev/null +++ b/lib/screens/channel/vod/stores/vod_list_store.dart @@ -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(); + + @readonly + var _isLoading = false; + + @readonly + var _error = ''; + + String? _cursor; + bool _hasMore = true; + + VodListStoreBase({ + required this.twitchApi, + required this.userId, + }); + + @action + Future 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 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 refresh() async { + _cursor = null; + _hasMore = true; + await fetchVideos(); + } +} diff --git a/lib/screens/channel/vod/stores/vod_list_store.g.dart b/lib/screens/channel/vod/stores/vod_list_store.g.dart new file mode 100644 index 00000000..dbd132b2 --- /dev/null +++ b/lib/screens/channel/vod/stores/vod_list_store.g.dart @@ -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 get videos { + _$_videosAtom.reportRead(); + return super._videos; + } + + @override + ObservableList get _videos => videos; + + @override + set _videos(ObservableList 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 fetchVideos() { + return _$fetchVideosAsyncAction.run(() => super.fetchVideos()); + } + + late final _$fetchMoreAsyncAction = AsyncAction( + 'VodListStoreBase.fetchMore', + context: context, + ); + + @override + Future fetchMore() { + return _$fetchMoreAsyncAction.run(() => super.fetchMore()); + } + + late final _$refreshAsyncAction = AsyncAction( + 'VodListStoreBase.refresh', + context: context, + ); + + @override + Future refresh() { + return _$refreshAsyncAction.run(() => super.refresh()); + } + + @override + String toString() { + return ''' + + '''; + } +} diff --git a/lib/screens/channel/vod/vod_card.dart b/lib/screens/channel/vod/vod_card.dart new file mode 100644 index 00000000..0fed02c5 --- /dev/null +++ b/lib/screens/channel/vod/vod_card.dart @@ -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(); + } +} diff --git a/lib/screens/channel/vod/vod_list.dart b/lib/screens/channel/vod/vod_list.dart new file mode 100644 index 00000000..d505c7d6 --- /dev/null +++ b/lib/screens/channel/vod/vod_list.dart @@ -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 createState() => _VodListState(); +} + +class _VodListState extends State { + 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( + 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]); + }, + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/screens/channel/vod/vod_player.dart b/lib/screens/channel/vod/vod_player.dart new file mode 100644 index 00000000..f579a686 --- /dev/null +++ b/lib/screens/channel/vod/vod_player.dart @@ -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 createState() => _VodPlayerState(); +} + +class _VodPlayerState extends State { + 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 {}, + ); + } 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 _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(); + } +} diff --git a/lib/screens/home/home.dart b/lib/screens/home/home.dart index 7bc450c7..434d172f 100644 --- a/lib/screens/home/home.dart +++ b/lib/screens/home/home.dart @@ -166,9 +166,8 @@ class _HomeState extends State { NavigationDestination( icon: Icon( Icons.favorite_border_rounded, - color: _homeStore.selectedIndex == 0 - ? theme.colorScheme.onSurface - : theme.colorScheme.onSurfaceVariant, + color: theme.colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), selectedIcon: Icon( Icons.favorite_rounded, @@ -180,11 +179,8 @@ class _HomeState extends State { NavigationDestination( icon: Icon( Icons.arrow_upward_rounded, - color: - _homeStore.selectedIndex == - (_authStore.isLoggedIn ? 1 : 0) - ? theme.colorScheme.onSurface - : theme.colorScheme.onSurfaceVariant, + color: theme.colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), selectedIcon: Icon( Icons.arrow_upward_rounded, @@ -196,11 +192,8 @@ class _HomeState extends State { NavigationDestination( icon: Icon( Icons.search_rounded, - color: - _homeStore.selectedIndex == - (_authStore.isLoggedIn ? 2 : 1) - ? theme.colorScheme.onSurface - : theme.colorScheme.onSurfaceVariant, + color: theme.colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), selectedIcon: Icon( Icons.search_rounded, diff --git a/lib/screens/settings/account/widgets/profile_card.dart b/lib/screens/settings/account/widgets/profile_card.dart index 7a55c3a5..666be38a 100644 --- a/lib/screens/settings/account/widgets/profile_card.dart +++ b/lib/screens/settings/account/widgets/profile_card.dart @@ -5,6 +5,7 @@ import 'package:frosty/screens/onboarding/login_webview.dart'; import 'package:frosty/screens/settings/account/account_options.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/utils/modal_bottom_sheet.dart'; +import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:frosty/widgets/profile_picture.dart'; class ProfileCard extends StatelessWidget { @@ -39,12 +40,69 @@ class ProfileCard extends StatelessWidget { ); } if (authStore.isLoggedIn && authStore.user.details != null) { + final hasToken = authStore.gqlToken != null; return ListTile( leading: ProfilePicture( userLogin: authStore.user.details!.login, radius: 12, ), - title: Text(authStore.user.details!.displayName), + title: GestureDetector( + onTap: () { + 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), onTap: () => _showAccountOptionsModalBottomSheet(context), ); diff --git a/lib/screens/settings/chat_settings.dart b/lib/screens/settings/chat_settings.dart index 6216ac26..a1e339b3 100644 --- a/lib/screens/settings/chat_settings.dart +++ b/lib/screens/settings/chat_settings.dart @@ -1,347 +1,369 @@ -import 'dart:io'; - -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:frosty/constants.dart'; -import 'package:frosty/screens/settings/stores/settings_store.dart'; -import 'package:frosty/screens/settings/widgets/settings_list_select.dart'; -import 'package:frosty/screens/settings/widgets/settings_list_slider.dart'; -import 'package:frosty/screens/settings/widgets/settings_list_switch.dart'; -import 'package:frosty/screens/settings/widgets/settings_muted_words.dart'; -import 'package:frosty/utils/context_extensions.dart'; -import 'package:frosty/widgets/frosty_cached_network_image.dart'; -import 'package:frosty/widgets/section_header.dart'; -import 'package:frosty/widgets/settings_page_layout.dart'; -import 'package:url_launcher/url_launcher.dart'; - -class ChatSettings extends StatefulWidget { - final SettingsStore settingsStore; - - const ChatSettings({super.key, required this.settingsStore}); - - @override - State createState() => _ChatSettingsState(); -} - -class _ChatSettingsState extends State { - var showPreview = false; - - @override - Widget build(BuildContext context) { - final settingsStore = widget.settingsStore; - - return Observer( - builder: (context) => SettingsPageLayout( - children: [ - const SectionHeader('Message sizing', isFirst: true), - ExpansionTile( - title: const Text('Preview'), - children: [ - Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - child: DefaultTextStyle( - style: DefaultTextStyle.of( - context, - ).style.copyWith(fontSize: settingsStore.fontSize), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text.rich( - TextSpan( - children: [ - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: FrostyCachedNetworkImage( - imageUrl: - 'https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/3', - height: - defaultBadgeSize * settingsStore.badgeScale, - width: - defaultBadgeSize * settingsStore.badgeScale, - ), - ), - const TextSpan(text: ' Badge and emote preview. '), - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: FrostyCachedNetworkImage( - imageUrl: - 'https://static-cdn.jtvnw.net/emoticons/v2/425618/default/dark/3.0', - height: - defaultEmoteSize * settingsStore.emoteScale, - width: - defaultEmoteSize * settingsStore.emoteScale, - ), - ), - ], - ), - textScaler: settingsStore.messageScale.textScaler, - ), - SizedBox(height: settingsStore.messageSpacing), - Text( - 'Hello! Here\'s a text preview.', - textScaler: settingsStore.messageScale.textScaler, - ), - SizedBox(height: settingsStore.messageSpacing), - Text( - 'And another for spacing without an emote!', - textScaler: settingsStore.messageScale.textScaler, - ), - ], - ), - ), - ), - ], - ), - SettingsListSlider( - title: 'Badge scale', - trailing: '${settingsStore.badgeScale.toStringAsFixed(2)}x', - value: settingsStore.badgeScale, - min: 0.25, - max: 3.0, - divisions: 11, - onChanged: (newValue) => settingsStore.badgeScale = newValue, - ), - SettingsListSlider( - title: 'Emote scale', - trailing: '${settingsStore.emoteScale.toStringAsFixed(2)}x', - value: settingsStore.emoteScale, - min: 0.25, - max: 3.0, - divisions: 11, - onChanged: (newValue) => settingsStore.emoteScale = newValue, - ), - SettingsListSlider( - title: 'Message scale', - trailing: '${settingsStore.messageScale.toStringAsFixed(2)}x', - value: settingsStore.messageScale, - min: 0.5, - max: 2.0, - divisions: 6, - onChanged: (newValue) => settingsStore.messageScale = newValue, - ), - SettingsListSlider( - title: 'Message spacing', - trailing: '${settingsStore.messageSpacing.toStringAsFixed(0)}px', - value: settingsStore.messageSpacing, - max: 30.0, - divisions: 15, - onChanged: (newValue) => settingsStore.messageSpacing = newValue, - ), - SettingsListSlider( - title: 'Font size', - trailing: settingsStore.fontSize.toInt().toString(), - value: settingsStore.fontSize, - min: 5, - max: 20, - divisions: 15, - onChanged: (newValue) => settingsStore.fontSize = newValue, - ), - const SectionHeader('Message appearance'), - SettingsListSwitch( - title: 'Show deleted messages', - subtitle: const Text( - 'Restores the original message of deleted messages.', - ), - value: settingsStore.showDeletedMessages, - onChanged: (newValue) => - settingsStore.showDeletedMessages = newValue, - ), - SettingsListSwitch( - title: 'Show message dividers', - value: settingsStore.showChatMessageDividers, - onChanged: (newValue) => - settingsStore.showChatMessageDividers = newValue, - ), - SettingsListSelect( - title: 'Message timestamps', - selectedOption: timestampNames[settingsStore.timestampType.index], - options: timestampNames, - onChanged: (newValue) => settingsStore.timestampType = - TimestampType.values[timestampNames.indexOf(newValue)], - ), - const SectionHeader('Delay and latency'), - SettingsListSwitch( - title: 'Sync message delay and stream latency', - value: settingsStore.autoSyncChatDelay, - onChanged: (newValue) => settingsStore.autoSyncChatDelay = newValue, - ), - if (!settingsStore.autoSyncChatDelay) - SettingsListSlider( - title: 'Message delay', - trailing: '${settingsStore.chatDelay.toInt()} seconds', - subtitle: - 'Adds a delay before each message is rendered in chat. ${Platform.isIOS ? '15 seconds is recommended for iOS.' : ''}', - value: settingsStore.chatDelay, - max: 30.0, - divisions: 30, - onChanged: (newValue) => settingsStore.chatDelay = newValue, - ), - const SectionHeader('Alerts'), - SettingsListSwitch( - title: 'Highlight first time chatters', - value: settingsStore.highlightFirstTimeChatter, - onChanged: (newValue) => - settingsStore.highlightFirstTimeChatter = newValue, - ), - SettingsListSwitch( - title: 'Show notices', - subtitle: const Text( - 'Shows notices such as subs and re-subs, announcements, and raids.', - ), - value: settingsStore.showUserNotices, - onChanged: (newValue) => settingsStore.showUserNotices = newValue, - ), - const SectionHeader('Layout'), - SettingsListSwitch( - title: 'Move emote menu button left', - subtitle: const Text( - 'Places the emote menu button on the left side to avoid accidental presses.', - ), - value: settingsStore.emoteMenuButtonOnLeft, - onChanged: (newValue) => - settingsStore.emoteMenuButtonOnLeft = newValue, - ), - SettingsListSwitch( - title: 'Persist chat tabs', - subtitle: const Text( - 'Secondary chat tabs are remembered when switching channels.', - ), - value: settingsStore.persistChatTabs, - onChanged: (newValue) { - settingsStore.persistChatTabs = newValue; - if (!newValue) { - settingsStore.secondaryTabs = []; - } - }, - ), - const SectionHeader('Landscape mode'), - SettingsListSwitch( - title: 'Move chat left', - value: settingsStore.landscapeChatLeftSide, - onChanged: (newValue) => - settingsStore.landscapeChatLeftSide = newValue, - ), - SettingsListSwitch( - title: 'Force vertical chat', - subtitle: const Text( - 'Intended for tablets and other larger displays.', - ), - value: settingsStore.landscapeForceVerticalChat, - onChanged: (newValue) => - settingsStore.landscapeForceVerticalChat = newValue, - ), - SettingsListSelect( - title: 'Fill notch side', - subtitle: - 'Overrides and fills the available space in devices with a display notch.', - selectedOption: - landscapeCutoutNames[settingsStore.landscapeCutout.index], - options: landscapeCutoutNames, - onChanged: (newValue) => settingsStore.landscapeCutout = - LandscapeCutoutType.values[landscapeCutoutNames.indexOf( - newValue, - )], - ), - SettingsListSlider( - title: 'Chat overlay opacity', - trailing: - '${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%', - subtitle: - 'Sets the opacity (transparency) of the overlay chat in fullscreen mode.', - value: settingsStore.fullScreenChatOverlayOpacity, - divisions: 10, - onChanged: (newValue) => - settingsStore.fullScreenChatOverlayOpacity = newValue, - ), - const SectionHeader('Muted keywords'), - SettingsMutedWords(settingsStore: settingsStore), - SettingsListSwitch( - title: 'Match whole words', - subtitle: const Text( - 'Only matches whole words instead of partial matches.', - ), - value: settingsStore.matchWholeWord, - onChanged: (newValue) => settingsStore.matchWholeWord = newValue, - ), - const SectionHeader('Autocomplete'), - SettingsListSwitch( - title: 'Show autocomplete bar', - subtitle: const Text( - 'Shows a bar containing matching emotes and mentions while typing.', - ), - value: settingsStore.autocomplete, - onChanged: (newValue) => settingsStore.autocomplete = newValue, - ), - const SectionHeader('Emotes and badges'), - SettingsListSwitch( - title: 'Show Twitch emotes', - value: settingsStore.showTwitchEmotes, - onChanged: (newValue) => settingsStore.showTwitchEmotes = newValue, - ), - SettingsListSwitch( - title: 'Show Twitch badges', - value: settingsStore.showTwitchBadges, - onChanged: (newValue) => settingsStore.showTwitchBadges = newValue, - ), - SettingsListSwitch( - title: 'Show 7TV emotes', - value: settingsStore.show7TVEmotes, - onChanged: (newValue) => settingsStore.show7TVEmotes = newValue, - ), - SettingsListSwitch( - title: 'Show BTTV emotes', - value: settingsStore.showBTTVEmotes, - onChanged: (newValue) => settingsStore.showBTTVEmotes = newValue, - ), - SettingsListSwitch( - title: 'Show BTTV badges', - value: settingsStore.showBTTVBadges, - onChanged: (newValue) => settingsStore.showBTTVBadges = newValue, - ), - SettingsListSwitch( - title: 'Show FFZ emotes', - value: settingsStore.showFFZEmotes, - onChanged: (newValue) => settingsStore.showFFZEmotes = newValue, - ), - SettingsListSwitch( - title: 'Show FFZ badges', - value: settingsStore.showFFZBadges, - onChanged: (newValue) => settingsStore.showFFZBadges = newValue, - ), - const SectionHeader('Recent messages'), - SettingsListSwitch( - title: 'Show historical recent messages', - subtitle: Text.rich( - TextSpan( - text: - 'Loads historical recent messages in chat through a third-party API service at ', - children: [ - TextSpan( - text: 'https://recent-messages.robotty.de/', - style: TextStyle( - color: context.colorScheme.primary, - decoration: TextDecoration.underline, - decorationColor: context.colorScheme.primary, - ), - recognizer: TapGestureRecognizer() - ..onTap = () => launchUrl( - Uri.parse('https://recent-messages.robotty.de/'), - mode: settingsStore.launchUrlExternal - ? LaunchMode.externalApplication - : LaunchMode.inAppBrowserView, - ), - ), - ], - ), - ), - value: settingsStore.showRecentMessages, - onChanged: (newValue) => - settingsStore.showRecentMessages = newValue, - ), - ], - ), - ); - } -} +import 'dart:io'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/constants.dart'; +import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:frosty/screens/settings/widgets/settings_list_select.dart'; +import 'package:frosty/screens/settings/widgets/settings_list_slider.dart'; +import 'package:frosty/screens/settings/widgets/settings_list_switch.dart'; +import 'package:frosty/screens/settings/widgets/settings_muted_words.dart'; +import 'package:frosty/utils/context_extensions.dart'; +import 'package:frosty/widgets/frosty_cached_network_image.dart'; +import 'package:frosty/widgets/section_header.dart'; +import 'package:frosty/widgets/settings_page_layout.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class ChatSettings extends StatelessWidget { + final SettingsStore settingsStore; + + const ChatSettings({super.key, required this.settingsStore}); + + @override + Widget build(BuildContext context) { + return Observer( + builder: (context) => SettingsPageLayout( + children: [ + const SectionHeader('General', isFirst: true), + SettingsListSwitch( + title: 'Keep screen on', + subtitle: const Text( + 'Prevents the screen from sleeping while a channel is open.', + ), + value: settingsStore.keepScreenAwake, + onChanged: (newValue) => + settingsStore.keepScreenAwake = newValue, + ), + SettingsListSwitch( + title: 'Autocomplete', + subtitle: const Text( + 'Shows matching emotes and mentions while typing.', + ), + value: settingsStore.autocomplete, + onChanged: (newValue) => + settingsStore.autocomplete = newValue, + ), + SettingsListSwitch( + title: 'Load recent messages', + subtitle: Text.rich( + TextSpan( + text: + 'Loads historical recent messages in chat through a third-party API service at ', + children: [ + TextSpan( + text: 'https://recent-messages.robotty.de/', + style: TextStyle( + color: context.colorScheme.primary, + decoration: TextDecoration.underline, + decorationColor: context.colorScheme.primary, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => launchUrl( + Uri.parse( + 'https://recent-messages.robotty.de/', + ), + mode: settingsStore.launchUrlExternal + ? LaunchMode.externalApplication + : LaunchMode.inAppBrowserView, + ), + ), + ], + ), + ), + value: settingsStore.showRecentMessages, + onChanged: (newValue) => + settingsStore.showRecentMessages = newValue, + ), + const SectionHeader('Message sizing'), + ExpansionTile( + title: const Text('Preview'), + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + child: DefaultTextStyle( + style: DefaultTextStyle.of( + context, + ).style.copyWith(fontSize: settingsStore.fontSize), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text.rich( + TextSpan( + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: FrostyCachedNetworkImage( + imageUrl: + 'https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/3', + height: + defaultBadgeSize * + settingsStore.badgeScale, + width: + defaultBadgeSize * + settingsStore.badgeScale, + ), + ), + const TextSpan( + text: ' Badge and emote preview. ', + ), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: FrostyCachedNetworkImage( + imageUrl: + 'https://static-cdn.jtvnw.net/emoticons/v2/425618/default/dark/3.0', + height: + defaultEmoteSize * + settingsStore.emoteScale, + width: + defaultEmoteSize * + settingsStore.emoteScale, + ), + ), + ], + ), + textScaler: settingsStore.messageScale.textScaler, + ), + SizedBox(height: settingsStore.messageSpacing), + Text( + 'Hello! Here\'s a text preview.', + textScaler: settingsStore.messageScale.textScaler, + ), + SizedBox(height: settingsStore.messageSpacing), + Text( + 'And another for spacing without an emote!', + textScaler: settingsStore.messageScale.textScaler, + ), + ], + ), + ), + ), + ], + ), + SettingsListSlider( + title: 'Badge scale', + trailing: '${settingsStore.badgeScale.toStringAsFixed(2)}x', + value: settingsStore.badgeScale, + min: 0.25, + max: 3.0, + divisions: 11, + onChanged: (newValue) => settingsStore.badgeScale = newValue, + ), + SettingsListSlider( + title: 'Emote scale', + trailing: '${settingsStore.emoteScale.toStringAsFixed(2)}x', + value: settingsStore.emoteScale, + min: 0.25, + max: 3.0, + divisions: 11, + onChanged: (newValue) => settingsStore.emoteScale = newValue, + ), + SettingsListSlider( + title: 'Message scale', + trailing: '${settingsStore.messageScale.toStringAsFixed(2)}x', + value: settingsStore.messageScale, + min: 0.5, + max: 2.0, + divisions: 6, + onChanged: (newValue) => settingsStore.messageScale = newValue, + ), + SettingsListSlider( + title: 'Message spacing', + trailing: + '${settingsStore.messageSpacing.toStringAsFixed(0)}px', + value: settingsStore.messageSpacing, + max: 30.0, + divisions: 15, + onChanged: (newValue) => + settingsStore.messageSpacing = newValue, + ), + SettingsListSlider( + title: 'Font size', + trailing: settingsStore.fontSize.toInt().toString(), + value: settingsStore.fontSize, + min: 5, + max: 20, + divisions: 15, + onChanged: (newValue) => settingsStore.fontSize = newValue, + ), + const SectionHeader('Message appearance'), + SettingsListSwitch( + title: 'Show deleted messages', + subtitle: const Text( + 'Restores the original message of deleted messages.', + ), + value: settingsStore.showDeletedMessages, + onChanged: (newValue) => + settingsStore.showDeletedMessages = newValue, + ), + SettingsListSwitch( + title: 'Show message dividers', + value: settingsStore.showChatMessageDividers, + onChanged: (newValue) => + settingsStore.showChatMessageDividers = newValue, + ), + SettingsListSelect( + title: 'Timestamps', + selectedOption: + timestampNames[settingsStore.timestampType.index], + options: timestampNames, + onChanged: (newValue) => settingsStore.timestampType = + TimestampType.values[timestampNames.indexOf(newValue)], + ), + const SectionHeader('Delay'), + SettingsListSwitch( + title: 'Auto-sync chat delay', + value: settingsStore.autoSyncChatDelay, + onChanged: (newValue) => + settingsStore.autoSyncChatDelay = newValue, + ), + if (!settingsStore.autoSyncChatDelay) + SettingsListSlider( + title: 'Chat delay', + trailing: '${settingsStore.chatDelay.toInt()} seconds', + subtitle: + 'Adds a delay before each message is rendered in chat. ${Platform.isIOS ? '15 seconds is recommended for iOS.' : ''}', + value: settingsStore.chatDelay, + max: 30.0, + divisions: 30, + onChanged: (newValue) => + settingsStore.chatDelay = newValue, + ), + const SectionHeader('Alerts'), + SettingsListSwitch( + title: 'Highlight first-time chatters', + value: settingsStore.highlightFirstTimeChatter, + onChanged: (newValue) => + settingsStore.highlightFirstTimeChatter = newValue, + ), + SettingsListSwitch( + title: 'Show notices', + subtitle: const Text( + 'Shows notices such as subs, announcements, and raids.', + ), + value: settingsStore.showUserNotices, + onChanged: (newValue) => + settingsStore.showUserNotices = newValue, + ), + const SectionHeader('Layout'), + SettingsListSwitch( + title: 'Emote menu on left', + subtitle: const Text( + 'Places the emote menu button on the left side.', + ), + value: settingsStore.emoteMenuButtonOnLeft, + onChanged: (newValue) => + settingsStore.emoteMenuButtonOnLeft = newValue, + ), + SettingsListSwitch( + title: 'Remember chat tabs', + subtitle: const Text( + 'Secondary chat tabs are kept when switching channels.', + ), + value: settingsStore.persistChatTabs, + onChanged: (newValue) { + settingsStore.persistChatTabs = newValue; + if (!newValue) { + settingsStore.secondaryTabs = []; + } + }, + ), + const SectionHeader('Landscape'), + SettingsListSwitch( + title: 'Chat on left side', + value: settingsStore.landscapeChatLeftSide, + onChanged: (newValue) => + settingsStore.landscapeChatLeftSide = newValue, + ), + SettingsListSwitch( + title: 'Force vertical chat', + subtitle: const Text( + 'Intended for tablets and larger displays.', + ), + value: settingsStore.landscapeForceVerticalChat, + onChanged: (newValue) => + settingsStore.landscapeForceVerticalChat = newValue, + ), + SettingsListSelect( + title: 'Notch fill', + subtitle: + 'Fills the display cutout area on the selected side.', + selectedOption: + landscapeCutoutNames[settingsStore.landscapeCutout.index], + options: landscapeCutoutNames, + onChanged: (newValue) => settingsStore.landscapeCutout = + LandscapeCutoutType.values[landscapeCutoutNames.indexOf( + newValue, + )], + ), + SettingsListSlider( + title: 'Overlay chat opacity', + trailing: + '${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%', + subtitle: + 'Opacity of the chat overlay in fullscreen mode.', + value: settingsStore.fullScreenChatOverlayOpacity, + divisions: 10, + onChanged: (newValue) => + settingsStore.fullScreenChatOverlayOpacity = newValue, + ), + const SectionHeader('Filtering'), + SettingsMutedWords(settingsStore: settingsStore), + SettingsListSwitch( + title: 'Match whole words', + subtitle: const Text( + 'Only matches whole words instead of partial matches.', + ), + value: settingsStore.matchWholeWord, + onChanged: (newValue) => + settingsStore.matchWholeWord = newValue, + ), + const SectionHeader('Emotes and badges'), + SettingsListSwitch( + title: 'Twitch emotes', + value: settingsStore.showTwitchEmotes, + onChanged: (newValue) => + settingsStore.showTwitchEmotes = newValue, + ), + SettingsListSwitch( + title: 'Twitch badges', + value: settingsStore.showTwitchBadges, + onChanged: (newValue) => + settingsStore.showTwitchBadges = newValue, + ), + SettingsListSwitch( + title: '7TV emotes', + value: settingsStore.show7TVEmotes, + onChanged: (newValue) => + settingsStore.show7TVEmotes = newValue, + ), + SettingsListSwitch( + title: 'BTTV emotes', + value: settingsStore.showBTTVEmotes, + onChanged: (newValue) => + settingsStore.showBTTVEmotes = newValue, + ), + SettingsListSwitch( + title: 'BTTV badges', + value: settingsStore.showBTTVBadges, + onChanged: (newValue) => + settingsStore.showBTTVBadges = newValue, + ), + SettingsListSwitch( + title: 'FFZ emotes', + value: settingsStore.showFFZEmotes, + onChanged: (newValue) => + settingsStore.showFFZEmotes = newValue, + ), + SettingsListSwitch( + title: 'FFZ badges', + value: settingsStore.showFFZBadges, + onChanged: (newValue) => + settingsStore.showFFZBadges = newValue, + ), + ], + ), + ); + } +} diff --git a/lib/screens/settings/general_settings.dart b/lib/screens/settings/general_settings.dart index e67149f6..6b13f477 100644 --- a/lib/screens/settings/general_settings.dart +++ b/lib/screens/settings/general_settings.dart @@ -25,12 +25,12 @@ class GeneralSettings extends StatelessWidget { AccentColorSetting(settingsStore: settingsStore), const SectionHeader('Stream card'), SettingsListSwitch( - title: 'Use large stream card', + title: 'Large stream cards', value: settingsStore.largeStreamCard, onChanged: (newValue) => settingsStore.largeStreamCard = newValue, ), SettingsListSwitch( - title: 'Show thumbnail', + title: 'Show thumbnails', value: settingsStore.showThumbnails, onChanged: (newValue) => settingsStore.showThumbnails = newValue, ), diff --git a/lib/screens/settings/stores/auth_store.dart b/lib/screens/settings/stores/auth_store.dart index 84d4a2ba..2f985eab 100644 --- a/lib/screens/settings/stores/auth_store.dart +++ b/lib/screens/settings/stores/auth_store.dart @@ -9,6 +9,7 @@ import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/main.dart'; import 'package:frosty/screens/settings/stores/user_store.dart'; +import 'package:frosty/services/cookie_extractor.dart'; import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:mobx/mobx.dart'; import 'package:webview_flutter/webview_flutter.dart'; @@ -27,6 +28,9 @@ abstract class AuthBase with Store { /// The shared_preferences key for the user token. static const _userTokenKey = 'user_token'; + /// The secure storage key for the GQL web cookie token. + static const _gqlTokenKey = 'gql_token'; + /// The Twitch API service for making requests. final TwitchApi twitchApi; @@ -49,6 +53,10 @@ abstract class AuthBase with Store { @readonly String? _token; + /// Web cookie token extracted from the login WebView for GQL ad-free playback. + @readonly + String? _gqlToken; + /// Whether the user is logged in or not. @readonly var _isLoggedIn = false; @@ -83,6 +91,7 @@ abstract class AuthBase with Store { // When redirected to the redirect_uri, there will be another redirect to "https://www.twitch.tv/?no-reload=true". // Checking for this will ensure that the user has automatically logged in to Twitch on the WebView itself. if (request.url == 'https://www.twitch.tv/?no-reload=true') { + _extractGqlToken(); if (routeAfter != null) { navigatorKey.currentState?.pop(); navigatorKey.currentState?.push( @@ -211,12 +220,27 @@ abstract class AuthBase with Store { AuthBase({required this.twitchApi}) : user = UserStore(twitchApi: twitchApi); + Future _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. @action Future init() async { try { // Read and set the currently stored user token, if any. _token = await _storage.read(key: _userTokenKey); + _gqlToken = await _storage.read(key: _gqlTokenKey); // If the token does not exist, get the default token. // Otherwise, log in. @@ -293,9 +317,11 @@ abstract class AuthBase with Store { Future logout() async { try { _stopReconnectLoop(); - // Delete the existing user token. + // Delete the existing user token and GQL token. await _storage.delete(key: _userTokenKey); + await _storage.delete(key: _gqlTokenKey); _token = null; + _gqlToken = null; // Clear the user info. user.dispose(); diff --git a/lib/screens/settings/stores/auth_store.g.dart b/lib/screens/settings/stores/auth_store.g.dart index e3f1a04f..5331d568 100644 --- a/lib/screens/settings/stores/auth_store.g.dart +++ b/lib/screens/settings/stores/auth_store.g.dart @@ -35,6 +35,26 @@ mixin _$AuthStore on AuthBase, Store { }); } + late final _$_gqlTokenAtom = Atom( + name: 'AuthBase._gqlToken', + context: context, + ); + + String? get gqlToken { + _$_gqlTokenAtom.reportRead(); + return super._gqlToken; + } + + @override + String? get _gqlToken => gqlToken; + + @override + set _gqlToken(String? value) { + _$_gqlTokenAtom.reportWrite(value, super._gqlToken, () { + super._gqlToken = value; + }); + } + late final _$_isLoggedInAtom = Atom( name: 'AuthBase._isLoggedIn', context: context, diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index 0ffe4c16..d5a7f03a 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -65,9 +65,8 @@ abstract class _SettingsStoreBase with Store { static const defaultShowVideo = true; static const defaultDefaultToHighestQuality = false; static const defaultUseTextureRendering = true; - static const defaultKeepScreenAwake = true; + static const defaultUseNativePlayer = false; - // Overlay defaults static const defaultShowOverlay = true; static const defaultToggleableOverlay = false; static const defaultShowLatency = false; @@ -85,9 +84,9 @@ abstract class _SettingsStoreBase with Store { @observable var useTextureRendering = defaultUseTextureRendering; - @JsonKey(defaultValue: defaultKeepScreenAwake) + @JsonKey(defaultValue: defaultUseNativePlayer) @observable - var keepScreenAwake = defaultKeepScreenAwake; + var useNativePlayer = defaultUseNativePlayer; // Overlay options @JsonKey(defaultValue: defaultShowOverlay) @@ -107,7 +106,7 @@ abstract class _SettingsStoreBase with Store { showVideo = defaultShowVideo; defaultToHighestQuality = defaultDefaultToHighestQuality; useTextureRendering = defaultUseTextureRendering; - keepScreenAwake = defaultKeepScreenAwake; + useNativePlayer = defaultUseNativePlayer; showOverlay = defaultShowOverlay; toggleableOverlay = defaultToggleableOverlay; @@ -115,44 +114,25 @@ abstract class _SettingsStoreBase with Store { } // * Chat Settings - // Message sizing defaults + + // -- Messages -- + // Sizing defaults static const defaultBadgeScale = 1.0; static const defaultEmoteScale = 1.0; static const defaultMessageScale = 1.0; static const defaultMessageSpacing = 8.0; static const defaultFontSize = 12.0; - // Message appearance defaults + // Appearance defaults static const defaultShowDeletedMessages = false; static const defaultShowChatMessageDividers = false; static const defaultTimestampType = TimestampType.disabled; - // Delay defaults - static const defaultAutoSyncChatDelay = false; - static const defaultChatDelay = 0.0; - // Alert defaults static const defaultHighlightFirstTimeChatter = true; static const defaultShowUserNotices = true; - // Layout defaults - static const defaultEmoteMenuButtonOnLeft = false; - - // Landscape mode defaults - static const defaultLandscapeChatLeftSide = false; - static const defaultLandscapeForceVerticalChat = false; - static const defaultLandscapeCutout = LandscapeCutoutType.none; - static const defaultChatWidth = 0.2; - static const defaultFullScreenChatOverlayOpacity = 0.5; - - // mute words defaults - static const defaultMutedWords = []; - static const defaultMatchWholeWord = true; - - // Autocomplete defaults - static const defaultAutocomplete = true; - - // Emotes and badges defaults + // -- Emotes & Badges -- static const defaultShowTwitchEmotes = true; static const defaultShowTwitchBadges = true; static const defaultShow7TVEmotes = true; @@ -161,14 +141,26 @@ abstract class _SettingsStoreBase with Store { static const defaultShowFFZEmotes = true; static const defaultShowFFZBadges = true; - // Recent messages defaults - static const defaultShowRecentMessages = false; - - // Chat tabs defaults + // -- Layout -- + static const defaultEmoteMenuButtonOnLeft = false; static const defaultPersistChatTabs = true; static const defaultSecondaryTabs = []; + 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 = []; + static const defaultMatchWholeWord = true; + + // Messages options @JsonKey(defaultValue: defaultBadgeScale) @observable var badgeScale = defaultBadgeScale; @@ -189,7 +181,6 @@ abstract class _SettingsStoreBase with Store { @observable var fontSize = defaultFontSize; - // Message appearance options @JsonKey(defaultValue: defaultShowDeletedMessages) @observable var showDeletedMessages = defaultShowDeletedMessages; @@ -205,24 +196,6 @@ abstract class _SettingsStoreBase with Store { @observable 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) @observable var highlightFirstTimeChatter = defaultHighlightFirstTimeChatter; @@ -231,38 +204,7 @@ abstract class _SettingsStoreBase with Store { @observable var showUserNotices = defaultShowUserNotices; - // Layout options - @JsonKey(defaultValue: defaultEmoteMenuButtonOnLeft) - @observable - var emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft; - - // Landscape mode options - @JsonKey(defaultValue: defaultLandscapeChatLeftSide) - @observable - var landscapeChatLeftSide = defaultLandscapeChatLeftSide; - - @JsonKey(defaultValue: defaultLandscapeForceVerticalChat) - @observable - var landscapeForceVerticalChat = defaultLandscapeForceVerticalChat; - - @JsonKey(defaultValue: defaultLandscapeCutout) - @observable - var landscapeCutout = defaultLandscapeCutout; - - @JsonKey(defaultValue: defaultChatWidth) - @observable - var chatWidth = defaultChatWidth; - - @JsonKey(defaultValue: defaultFullScreenChatOverlayOpacity) - @observable - var fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity; - - // Autocomplete options - @JsonKey(defaultValue: defaultAutocomplete) - @observable - var autocomplete = defaultAutocomplete; - - // Emotes and badges + // Emotes & Badges options @JsonKey(defaultValue: defaultShowTwitchEmotes) @observable var showTwitchEmotes = defaultShowTwitchEmotes; @@ -291,12 +233,11 @@ abstract class _SettingsStoreBase with Store { @observable var showFFZBadges = defaultShowFFZBadges; - // Recent messages - @JsonKey(defaultValue: defaultShowRecentMessages) + // Layout options + @JsonKey(defaultValue: defaultEmoteMenuButtonOnLeft) @observable - var showRecentMessages = defaultShowRecentMessages; + var emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft; - // Chat tabs @JsonKey(defaultValue: defaultPersistChatTabs) @observable var persistChatTabs = defaultPersistChatTabs; @@ -305,6 +246,55 @@ abstract class _SettingsStoreBase with Store { @observable var secondaryTabs = defaultSecondaryTabs; + @JsonKey(defaultValue: defaultLandscapeChatLeftSide) + @observable + var landscapeChatLeftSide = defaultLandscapeChatLeftSide; + + @JsonKey(defaultValue: defaultLandscapeForceVerticalChat) + @observable + var landscapeForceVerticalChat = defaultLandscapeForceVerticalChat; + + @JsonKey(defaultValue: defaultLandscapeCutout) + @observable + var landscapeCutout = defaultLandscapeCutout; + + @JsonKey(defaultValue: defaultChatWidth) + @observable + var chatWidth = defaultChatWidth; + + @JsonKey(defaultValue: defaultFullScreenChatOverlayOpacity) + @observable + var fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity; + + // Behavior options + @JsonKey(defaultValue: defaultKeepScreenAwake) + @observable + var keepScreenAwake = defaultKeepScreenAwake; + + @JsonKey(defaultValue: defaultAutocomplete) + @observable + var autocomplete = defaultAutocomplete; + + @JsonKey(defaultValue: defaultShowRecentMessages) + @observable + var showRecentMessages = defaultShowRecentMessages; + + @JsonKey(defaultValue: defaultAutoSyncChatDelay) + @observable + var autoSyncChatDelay = defaultAutoSyncChatDelay; + + @JsonKey(defaultValue: defaultChatDelay) + @observable + var chatDelay = defaultChatDelay; + + @JsonKey(includeFromJson: false, includeToJson: false) + @observable + var syncedChatDelay = defaultChatDelay; + + @computed + double get effectiveChatDelay => + autoSyncChatDelay ? syncedChatDelay : chatDelay; + @JsonKey(defaultValue: defaultMutedWords) @observable List mutedWords = defaultMutedWords; @@ -315,36 +305,19 @@ abstract class _SettingsStoreBase with Store { @action void resetChatSettings() { + // Messages badgeScale = defaultBadgeScale; emoteScale = defaultEmoteScale; messageScale = defaultMessageScale; messageSpacing = defaultMessageSpacing; fontSize = defaultFontSize; - showDeletedMessages = defaultShowDeletedMessages; showChatMessageDividers = defaultShowChatMessageDividers; timestampType = defaultTimestampType; - - autoSyncChatDelay = defaultAutoSyncChatDelay; - chatDelay = defaultChatDelay; - syncedChatDelay = defaultChatDelay; - highlightFirstTimeChatter = defaultHighlightFirstTimeChatter; showUserNotices = defaultShowUserNotices; - emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft; - - landscapeChatLeftSide = defaultLandscapeChatLeftSide; - landscapeForceVerticalChat = defaultLandscapeForceVerticalChat; - landscapeCutout = defaultLandscapeCutout; - chatWidth = defaultChatWidth; - fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity; - - mutedWords = defaultMutedWords; - matchWholeWord = defaultMatchWholeWord; - - autocomplete = defaultAutocomplete; - + // Emotes & Badges showTwitchEmotes = defaultShowTwitchEmotes; showTwitchBadges = defaultShowTwitchBadges; show7TVEmotes = defaultShow7TVEmotes; @@ -353,10 +326,25 @@ abstract class _SettingsStoreBase with Store { showFFZEmotes = defaultShowFFZEmotes; showFFZBadges = defaultShowFFZBadges; - showRecentMessages = defaultShowRecentMessages; - + // Layout + emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft; persistChatTabs = defaultPersistChatTabs; secondaryTabs = defaultSecondaryTabs; + landscapeChatLeftSide = defaultLandscapeChatLeftSide; + landscapeForceVerticalChat = defaultLandscapeForceVerticalChat; + landscapeCutout = defaultLandscapeCutout; + chatWidth = defaultChatWidth; + fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity; + + // Behavior + keepScreenAwake = defaultKeepScreenAwake; + autocomplete = defaultAutocomplete; + showRecentMessages = defaultShowRecentMessages; + autoSyncChatDelay = defaultAutoSyncChatDelay; + chatDelay = defaultChatDelay; + syncedChatDelay = defaultChatDelay; + mutedWords = defaultMutedWords; + matchWholeWord = defaultMatchWholeWord; } // * Other settings diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index 1955c3f5..2e3313e0 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -23,7 +23,7 @@ SettingsStore _$SettingsStoreFromJson( ..showVideo = json['showVideo'] as bool? ?? true ..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false ..useTextureRendering = json['useTextureRendering'] as bool? ?? true - ..keepScreenAwake = json['keepScreenAwake'] as bool? ?? true + ..useNativePlayer = json['useNativePlayer'] as bool? ?? false ..showOverlay = json['showOverlay'] as bool? ?? true ..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false ..showLatency = json['showLatency'] as bool? ?? false @@ -41,12 +41,23 @@ SettingsStore _$SettingsStoreFromJson( unknownValue: TimestampType.disabled, ) ?? TimestampType.disabled - ..autoSyncChatDelay = json['autoSyncChatDelay'] as bool? ?? false - ..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0 ..highlightFirstTimeChatter = json['highlightFirstTimeChatter'] as bool? ?? true ..showUserNotices = json['showUserNotices'] as bool? ?? true + ..showTwitchEmotes = json['showTwitchEmotes'] as bool? ?? true + ..showTwitchBadges = json['showTwitchBadges'] as bool? ?? true + ..show7TVEmotes = json['show7TVEmotes'] as bool? ?? true + ..showBTTVEmotes = json['showBTTVEmotes'] as bool? ?? true + ..showBTTVBadges = json['showBTTVBadges'] as bool? ?? true + ..showFFZEmotes = json['showFFZEmotes'] as bool? ?? true + ..showFFZBadges = json['showFFZBadges'] as bool? ?? true ..emoteMenuButtonOnLeft = json['emoteMenuButtonOnLeft'] as bool? ?? false + ..persistChatTabs = json['persistChatTabs'] as bool? ?? true + ..secondaryTabs = + (json['secondaryTabs'] as List?) + ?.map((e) => PersistedChatTab.fromJson(e as Map)) + .toList() ?? + [] ..landscapeChatLeftSide = json['landscapeChatLeftSide'] as bool? ?? false ..landscapeForceVerticalChat = json['landscapeForceVerticalChat'] as bool? ?? false @@ -59,21 +70,11 @@ SettingsStore _$SettingsStoreFromJson( ..chatWidth = (json['chatWidth'] as num?)?.toDouble() ?? 0.2 ..fullScreenChatOverlayOpacity = (json['fullScreenChatOverlayOpacity'] as num?)?.toDouble() ?? 0.5 + ..keepScreenAwake = json['keepScreenAwake'] as bool? ?? true ..autocomplete = json['autocomplete'] as bool? ?? true - ..showTwitchEmotes = json['showTwitchEmotes'] as bool? ?? true - ..showTwitchBadges = json['showTwitchBadges'] as bool? ?? true - ..show7TVEmotes = json['show7TVEmotes'] as bool? ?? true - ..showBTTVEmotes = json['showBTTVEmotes'] as bool? ?? true - ..showBTTVBadges = json['showBTTVBadges'] as bool? ?? true - ..showFFZEmotes = json['showFFZEmotes'] as bool? ?? true - ..showFFZBadges = json['showFFZBadges'] as bool? ?? true ..showRecentMessages = json['showRecentMessages'] as bool? ?? false - ..persistChatTabs = json['persistChatTabs'] as bool? ?? true - ..secondaryTabs = - (json['secondaryTabs'] as List?) - ?.map((e) => PersistedChatTab.fromJson(e as Map)) - .toList() ?? - [] + ..autoSyncChatDelay = json['autoSyncChatDelay'] as bool? ?? false + ..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0 ..mutedWords = (json['mutedWords'] as List?) ?.map((e) => e as String) @@ -101,7 +102,7 @@ Map _$SettingsStoreToJson( 'showVideo': instance.showVideo, 'defaultToHighestQuality': instance.defaultToHighestQuality, 'useTextureRendering': instance.useTextureRendering, - 'keepScreenAwake': instance.keepScreenAwake, + 'useNativePlayer': instance.useNativePlayer, 'showOverlay': instance.showOverlay, 'toggleableOverlay': instance.toggleableOverlay, 'showLatency': instance.showLatency, @@ -113,17 +114,8 @@ Map _$SettingsStoreToJson( 'showDeletedMessages': instance.showDeletedMessages, 'showChatMessageDividers': instance.showChatMessageDividers, 'timestampType': _$TimestampTypeEnumMap[instance.timestampType]!, - 'autoSyncChatDelay': instance.autoSyncChatDelay, - 'chatDelay': instance.chatDelay, 'highlightFirstTimeChatter': instance.highlightFirstTimeChatter, 'showUserNotices': instance.showUserNotices, - 'emoteMenuButtonOnLeft': instance.emoteMenuButtonOnLeft, - 'landscapeChatLeftSide': instance.landscapeChatLeftSide, - 'landscapeForceVerticalChat': instance.landscapeForceVerticalChat, - 'landscapeCutout': _$LandscapeCutoutTypeEnumMap[instance.landscapeCutout]!, - 'chatWidth': instance.chatWidth, - 'fullScreenChatOverlayOpacity': instance.fullScreenChatOverlayOpacity, - 'autocomplete': instance.autocomplete, 'showTwitchEmotes': instance.showTwitchEmotes, 'showTwitchBadges': instance.showTwitchBadges, 'show7TVEmotes': instance.show7TVEmotes, @@ -131,9 +123,19 @@ Map _$SettingsStoreToJson( 'showBTTVBadges': instance.showBTTVBadges, 'showFFZEmotes': instance.showFFZEmotes, 'showFFZBadges': instance.showFFZBadges, - 'showRecentMessages': instance.showRecentMessages, + 'emoteMenuButtonOnLeft': instance.emoteMenuButtonOnLeft, 'persistChatTabs': instance.persistChatTabs, 'secondaryTabs': instance.secondaryTabs, + 'landscapeChatLeftSide': instance.landscapeChatLeftSide, + 'landscapeForceVerticalChat': instance.landscapeForceVerticalChat, + 'landscapeCutout': _$LandscapeCutoutTypeEnumMap[instance.landscapeCutout]!, + 'chatWidth': instance.chatWidth, + 'fullScreenChatOverlayOpacity': instance.fullScreenChatOverlayOpacity, + 'keepScreenAwake': instance.keepScreenAwake, + 'autocomplete': instance.autocomplete, + 'showRecentMessages': instance.showRecentMessages, + 'autoSyncChatDelay': instance.autoSyncChatDelay, + 'chatDelay': instance.chatDelay, 'mutedWords': instance.mutedWords, 'matchWholeWord': instance.matchWholeWord, 'shareCrashLogsAndAnalytics': instance.shareCrashLogsAndAnalytics, @@ -325,21 +327,21 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } - late final _$keepScreenAwakeAtom = Atom( - name: '_SettingsStoreBase.keepScreenAwake', + late final _$useNativePlayerAtom = Atom( + name: '_SettingsStoreBase.useNativePlayer', context: context, ); @override - bool get keepScreenAwake { - _$keepScreenAwakeAtom.reportRead(); - return super.keepScreenAwake; + bool get useNativePlayer { + _$useNativePlayerAtom.reportRead(); + return super.useNativePlayer; } @override - set keepScreenAwake(bool value) { - _$keepScreenAwakeAtom.reportWrite(value, super.keepScreenAwake, () { - super.keepScreenAwake = value; + set useNativePlayer(bool value) { + _$useNativePlayerAtom.reportWrite(value, super.useNativePlayer, () { + super.useNativePlayer = value; }); } @@ -545,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( name: '_SettingsStoreBase.highlightFirstTimeChatter', 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( name: '_SettingsStoreBase.showTwitchEmotes', context: context, @@ -907,22 +713,26 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } - late final _$showRecentMessagesAtom = Atom( - name: '_SettingsStoreBase.showRecentMessages', + late final _$emoteMenuButtonOnLeftAtom = Atom( + name: '_SettingsStoreBase.emoteMenuButtonOnLeft', context: context, ); @override - bool get showRecentMessages { - _$showRecentMessagesAtom.reportRead(); - return super.showRecentMessages; + bool get emoteMenuButtonOnLeft { + _$emoteMenuButtonOnLeftAtom.reportRead(); + return super.emoteMenuButtonOnLeft; } @override - set showRecentMessages(bool value) { - _$showRecentMessagesAtom.reportWrite(value, super.showRecentMessages, () { - super.showRecentMessages = value; - }); + set emoteMenuButtonOnLeft(bool value) { + _$emoteMenuButtonOnLeftAtom.reportWrite( + value, + super.emoteMenuButtonOnLeft, + () { + super.emoteMenuButtonOnLeft = value; + }, + ); } late final _$persistChatTabsAtom = Atom( @@ -961,6 +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( name: '_SettingsStoreBase.mutedWords', context: context, @@ -1165,7 +1185,7 @@ launchUrlExternal: ${launchUrlExternal}, showVideo: ${showVideo}, defaultToHighestQuality: ${defaultToHighestQuality}, useTextureRendering: ${useTextureRendering}, -keepScreenAwake: ${keepScreenAwake}, +useNativePlayer: ${useNativePlayer}, showOverlay: ${showOverlay}, toggleableOverlay: ${toggleableOverlay}, showLatency: ${showLatency}, @@ -1177,18 +1197,8 @@ fontSize: ${fontSize}, showDeletedMessages: ${showDeletedMessages}, showChatMessageDividers: ${showChatMessageDividers}, timestampType: ${timestampType}, -autoSyncChatDelay: ${autoSyncChatDelay}, -chatDelay: ${chatDelay}, -syncedChatDelay: ${syncedChatDelay}, highlightFirstTimeChatter: ${highlightFirstTimeChatter}, showUserNotices: ${showUserNotices}, -emoteMenuButtonOnLeft: ${emoteMenuButtonOnLeft}, -landscapeChatLeftSide: ${landscapeChatLeftSide}, -landscapeForceVerticalChat: ${landscapeForceVerticalChat}, -landscapeCutout: ${landscapeCutout}, -chatWidth: ${chatWidth}, -fullScreenChatOverlayOpacity: ${fullScreenChatOverlayOpacity}, -autocomplete: ${autocomplete}, showTwitchEmotes: ${showTwitchEmotes}, showTwitchBadges: ${showTwitchBadges}, show7TVEmotes: ${show7TVEmotes}, @@ -1196,9 +1206,20 @@ showBTTVEmotes: ${showBTTVEmotes}, showBTTVBadges: ${showBTTVBadges}, showFFZEmotes: ${showFFZEmotes}, showFFZBadges: ${showFFZBadges}, -showRecentMessages: ${showRecentMessages}, +emoteMenuButtonOnLeft: ${emoteMenuButtonOnLeft}, persistChatTabs: ${persistChatTabs}, secondaryTabs: ${secondaryTabs}, +landscapeChatLeftSide: ${landscapeChatLeftSide}, +landscapeForceVerticalChat: ${landscapeForceVerticalChat}, +landscapeCutout: ${landscapeCutout}, +chatWidth: ${chatWidth}, +fullScreenChatOverlayOpacity: ${fullScreenChatOverlayOpacity}, +keepScreenAwake: ${keepScreenAwake}, +autocomplete: ${autocomplete}, +showRecentMessages: ${showRecentMessages}, +autoSyncChatDelay: ${autoSyncChatDelay}, +chatDelay: ${chatDelay}, +syncedChatDelay: ${syncedChatDelay}, mutedWords: ${mutedWords}, matchWholeWord: ${matchWholeWord}, shareCrashLogsAndAnalytics: ${shareCrashLogsAndAnalytics}, diff --git a/lib/screens/settings/video_settings.dart b/lib/screens/settings/video_settings.dart index d6b2b72b..4e2aedd4 100644 --- a/lib/screens/settings/video_settings.dart +++ b/lib/screens/settings/video_settings.dart @@ -20,10 +20,20 @@ class VideoSettings extends StatelessWidget { children: [ const SectionHeader('Player', isFirst: true), SettingsListSwitch( - title: 'Enable video', + title: 'Show video player', value: settingsStore.showVideo, onChanged: (newValue) => settingsStore.showVideo = newValue, ), + if (settingsStore.showVideo) + SettingsListSwitch( + title: 'Native player (experimental)', + subtitle: const Text( + 'More performant video player with auto picture-in-picture and quality selection.', + ), + value: settingsStore.useNativePlayer, + onChanged: (newValue) => + settingsStore.useNativePlayer = newValue, + ), if (!Platform.isIOS || isIPad()) SettingsListSwitch( title: 'Default to highest quality', @@ -41,14 +51,6 @@ class VideoSettings extends StatelessWidget { onChanged: (newValue) => settingsStore.useTextureRendering = newValue, ), - SettingsListSwitch( - title: 'Keep screen awake', - subtitle: const Text( - 'Prevents the screen from sleeping while a channel is open.', - ), - value: settingsStore.keepScreenAwake, - onChanged: (newValue) => settingsStore.keepScreenAwake = newValue, - ), const SectionHeader('Overlay'), SettingsListSwitch( title: 'Use custom video overlay', @@ -59,12 +61,13 @@ class VideoSettings extends StatelessWidget { onChanged: (newValue) => settingsStore.showOverlay = newValue, ), SettingsListSwitch( - title: 'Long-press player to toggle overlay', + title: 'Toggle overlay on long-press', subtitle: const Text( - 'Allows switching between Twitch\'s overlay and the custom overlay.', + 'Switch between Twitch\'s overlay and the custom overlay.', ), value: settingsStore.toggleableOverlay, - onChanged: (newValue) => settingsStore.toggleableOverlay = newValue, + onChanged: (newValue) => + settingsStore.toggleableOverlay = newValue, ), SettingsListSwitch( title: 'Show latency', diff --git a/lib/services/cookie_extractor.dart b/lib/services/cookie_extractor.dart new file mode 100644 index 00000000..cca2b028 --- /dev/null +++ b/lib/services/cookie_extractor.dart @@ -0,0 +1,9 @@ +import 'package:flutter/services.dart'; + +class CookieExtractor { + static const _channel = MethodChannel('frosty/cookie_extractor'); + + static Future extractTwitchAuthToken() async { + return await _channel.invokeMethod('extractTwitchAuthToken'); + } +} diff --git a/lib/theme.dart b/lib/theme.dart index 06216a11..a811afc1 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -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( elevation: 0, backgroundColor: backgroundColor, diff --git a/lib/utils/context_extensions.dart b/lib/utils/context_extensions.dart index f9cf83bf..f8a5c1df 100644 --- a/lib/utils/context_extensions.dart +++ b/lib/utils/context_extensions.dart @@ -3,6 +3,7 @@ import 'package:frosty/apis/bttv_api.dart'; import 'package:frosty/apis/ffz_api.dart'; import 'package:frosty/apis/seventv_api.dart'; import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/stores/global_assets_store.dart'; @@ -71,6 +72,9 @@ extension ContextExtensions on BuildContext { /// Gets TwitchApi from provider TwitchApi get twitchApi => read(); + /// Gets TwitchGqlApi from provider + TwitchGqlApi get twitchGqlApi => read(); + /// Gets AuthStore from provider AuthStore get authStore => read(); diff --git a/lib/widgets/expandable_section_header.dart b/lib/widgets/expandable_section_header.dart index 64935101..ede5540f 100644 --- a/lib/widgets/expandable_section_header.dart +++ b/lib/widgets/expandable_section_header.dart @@ -51,11 +51,11 @@ class ExpandableSectionHeader extends StatelessWidget { child: Text( text.toUpperCase(), style: TextStyle( - fontSize: fontSize ?? 13, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - color: context.colorScheme.onSurface.withValues( - alpha: 0.6, + fontSize: fontSize ?? 12, + fontWeight: FontWeight.w500, + letterSpacing: 0.8, + color: context.colorScheme.onSurfaceVariant.withValues( + alpha: 0.5, ), ), ), @@ -65,7 +65,9 @@ class ExpandableSectionHeader extends StatelessWidget { duration: const Duration(milliseconds: 200), child: Icon( Icons.keyboard_arrow_down, - color: context.colorScheme.onSurfaceVariant, + color: context.colorScheme.onSurfaceVariant.withValues( + alpha: 0.5, + ), size: 20, ), ), diff --git a/lib/widgets/section_header.dart b/lib/widgets/section_header.dart index 877df882..ed598760 100644 --- a/lib/widgets/section_header.dart +++ b/lib/widgets/section_header.dart @@ -38,10 +38,12 @@ class SectionHeader extends StatelessWidget { Text( text.toUpperCase(), style: TextStyle( - fontSize: fontSize ?? 13, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - color: context.colorScheme.onSurfaceVariant, + fontSize: fontSize ?? 12, + fontWeight: FontWeight.w500, + letterSpacing: 0.8, + color: context.colorScheme.onSurfaceVariant.withValues( + alpha: 0.5, + ), ), ), ], diff --git a/lib/widgets/settings_page_layout.dart b/lib/widgets/settings_page_layout.dart index 50864ddb..d77be3f6 100644 --- a/lib/widgets/settings_page_layout.dart +++ b/lib/widgets/settings_page_layout.dart @@ -41,7 +41,7 @@ class _SettingsPageLayoutState extends State { // Responsive values based on orientation final listPadding = EdgeInsets.only( - top: isPortrait ? 116 : 92, + top: isPortrait ? 124 : 100, bottom: widget.hasBottomPadding ? context.safePaddingBottom + 8 : 0, ); diff --git a/pubspec.lock b/pubspec.lock index 6eb900ef..4c60704f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -97,6 +97,15 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -181,10 +190,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -313,6 +322,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -449,6 +466,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + floating: + dependency: transitive + description: + name: floating + sha256: e51ce1dbcab3ea83da0ad1b07f2091b99c0e8680184c780a000567f41234d454 + url: "https://pub.dev" + source: hosted + version: "6.0.0" flutter: dependency: "direct main" description: flutter @@ -772,18 +797,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: @@ -1242,10 +1267,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3be38265..83ca06c5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -71,6 +71,10 @@ dependencies: app_links: ^7.0.0 extended_text_field: ^16.0.2 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: flutter_test: diff --git a/test/apis/twitch_api_test.dart b/test/apis/twitch_api_test.dart index 5b5df81a..d100ace3 100644 --- a/test/apis/twitch_api_test.dart +++ b/test/apis/twitch_api_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/models/emotes.dart'; +import 'package:frosty/models/video.dart'; import 'package:http_mock_adapter/http_mock_adapter.dart'; import '../fixtures/api_responses.dart'; @@ -389,4 +390,63 @@ void main() { 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': >[], + 'pagination': {}, + }), + queryParameters: { + 'user_id': '111', + 'first': '20', + 'type': 'archive', + 'after': 'page2', + }, + ); + + final videos = await api.getVideos(userId: '111', cursor: 'page2'); + + expect(videos.data, isEmpty); + }); + }); } diff --git a/test/apis/twitch_gql_api_test.dart b/test/apis/twitch_gql_api_test.dart new file mode 100644 index 00000000..d4817997 --- /dev/null +++ b/test/apis/twitch_gql_api_test.dart @@ -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': >[], + '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': >[], + '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()), + ); + }); + }); +} diff --git a/test/models/gql_chat_comment_test.dart b/test/models/gql_chat_comment_test.dart new file mode 100644 index 00000000..640ee07c --- /dev/null +++ b/test/models/gql_chat_comment_test.dart @@ -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': >[], + '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': >[], + '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()), + ); + }); + + 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'], ''); + }); + }); +} diff --git a/test/models/video_test.dart b/test/models/video_test.dart new file mode 100644 index 00000000..2cc218bc --- /dev/null +++ b/test/models/video_test.dart @@ -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': >[], + 'pagination': {}, + }; + + 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); + }); + }); +}