Compare commits
28 commits
main
...
vod-chat-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e73d9b255f | ||
|
|
6695c13455 | ||
|
|
d22c980454 | ||
|
|
3dbdd3501a | ||
|
|
f9141e3bae | ||
|
|
ad718e1bb2 | ||
|
|
7839dc9135 | ||
|
|
3b0e7f9c08 | ||
|
|
0a52ca3724 | ||
|
|
9c1be7e121 | ||
|
|
8b8b190316 | ||
|
|
9529438a3f | ||
|
|
d01ee26fca | ||
|
|
e7c726daeb | ||
|
|
144c5e3f1f | ||
|
|
cb79dfd233 | ||
|
|
97d371c0e1 | ||
|
|
adb166ff92 | ||
|
|
a69eb5ddfa | ||
|
|
649710c758 | ||
|
|
8d5ccb699e | ||
|
|
d048875490 | ||
|
|
f27de6dc3d | ||
|
|
b2039e1cd8 | ||
|
|
6e5da0bae0 | ||
|
|
418c51ce6d | ||
|
|
1779520db8 | ||
|
|
1020b37e2f |
59 changed files with 4527 additions and 826 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieExtractorPlugin.swift; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
95D7E205EE04D01F6996ECFC /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
26
ios/Runner/CookieExtractorPlugin.swift
Normal file
26
ios/Runner/CookieExtractorPlugin.swift
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import Flutter
|
||||
import WebKit
|
||||
|
||||
class CookieExtractorPlugin: NSObject, FlutterPlugin {
|
||||
static func register(with registrar: FlutterPluginRegistrar) {
|
||||
let channel = FlutterMethodChannel(
|
||||
name: "frosty/cookie_extractor",
|
||||
binaryMessenger: registrar.messenger()
|
||||
)
|
||||
let instance = CookieExtractorPlugin()
|
||||
registrar.addMethodCallDelegate(instance, channel: channel)
|
||||
}
|
||||
|
||||
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
if call.method == "extractTwitchAuthToken" {
|
||||
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
|
||||
let authCookie = cookies.first {
|
||||
$0.name == "auth-token" && $0.domain.contains("twitch.tv")
|
||||
}
|
||||
result(authCookie?.value)
|
||||
}
|
||||
} else {
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -54,5 +54,9 @@
|
|||
<true/>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -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<VideosTwitch> getVideos({
|
||||
required String userId,
|
||||
String? cursor,
|
||||
}) async {
|
||||
final queryParams = <String, String>{
|
||||
'user_id': userId,
|
||||
'first': '20',
|
||||
'type': 'archive',
|
||||
};
|
||||
if (cursor != null) queryParams['after'] = cursor;
|
||||
|
||||
final data = await get<JsonMap>('/videos', queryParameters: queryParams);
|
||||
|
||||
return VideosTwitch.fromJson(data);
|
||||
}
|
||||
|
||||
/// Returns a [StreamsTwitch] object that contains the list of streams under the given game/category ID.
|
||||
Future<StreamsTwitch> getStreamsUnderCategory({
|
||||
required String gameId,
|
||||
|
|
|
|||
113
lib/apis/twitch_gql_api.dart
Normal file
113
lib/apis/twitch_gql_api.dart
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:frosty/apis/base_api_client.dart';
|
||||
import 'package:frosty/constants.dart';
|
||||
import 'package:frosty/models/gql_chat_comment.dart';
|
||||
import 'package:frosty/models/playback_access_token.dart';
|
||||
|
||||
/// Twitch GQL API client for playback access tokens and VOD chat.
|
||||
///
|
||||
/// Uses the Twitch web client ID for playback tokens and the mobile
|
||||
/// client ID for VOD chat (bypasses Client-Integrity checks).
|
||||
class TwitchGqlApi extends BaseApiClient {
|
||||
TwitchGqlApi(Dio dio) : super(dio, 'https://gql.twitch.tv');
|
||||
|
||||
/// Fetches a playback access token for the given channel [login].
|
||||
///
|
||||
/// When [authToken] is provided, the request is authenticated which may
|
||||
/// grant ad-free playback for subscribers and Turbo users.
|
||||
Future<PlaybackAccessToken> getPlaybackAccessToken({
|
||||
required String login,
|
||||
String? authToken,
|
||||
}) async {
|
||||
final headers = <String, String>{
|
||||
'Client-ID': twitchGqlClientId,
|
||||
if (authToken != null) 'Authorization': 'OAuth $authToken',
|
||||
};
|
||||
|
||||
final body = {
|
||||
'operationName': 'PlaybackAccessToken_Template',
|
||||
'query':
|
||||
'query PlaybackAccessToken_Template(\$login: String!, \$isLive: Boolean!, \$vodID: ID!, \$isVod: Boolean!, \$playerType: String!) { streamPlaybackAccessToken(channelName: \$login, params: {platform: "web", playerBackend: "mediaplayer", playerType: \$playerType}) @include(if: \$isLive) { value signature __typename } videoPlaybackAccessToken(id: \$vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: \$playerType}) @include(if: \$isVod) { value signature __typename } }',
|
||||
'variables': {
|
||||
'isLive': true,
|
||||
'login': login,
|
||||
'isVod': false,
|
||||
'vodID': '',
|
||||
'playerType': 'site',
|
||||
},
|
||||
};
|
||||
|
||||
final response = await post<JsonMap>(
|
||||
'/gql',
|
||||
data: body,
|
||||
headers: headers,
|
||||
);
|
||||
|
||||
return PlaybackAccessToken.fromGqlResponse(response);
|
||||
}
|
||||
|
||||
/// Builds the HLS stream URL for the given channel [login] and [token].
|
||||
String buildHlsUrl({
|
||||
required String login,
|
||||
required PlaybackAccessToken token,
|
||||
}) {
|
||||
final random = Random().nextInt(999999);
|
||||
final encodedToken = Uri.encodeComponent(token.value);
|
||||
|
||||
return 'https://usher.ttvnw.net/api/channel/hls/$login.m3u8'
|
||||
'?sig=${token.signature}'
|
||||
'&token=$encodedToken'
|
||||
'&allow_source=true'
|
||||
'&allow_audio_only=true'
|
||||
'&fast_bread=true'
|
||||
'&supported_codecs=h264'
|
||||
'&platform=web'
|
||||
'&playlist_include_framerate=true'
|
||||
'&p=$random';
|
||||
}
|
||||
|
||||
/// Fetches VOD chat comments for the given [videoId].
|
||||
///
|
||||
/// Pass [contentOffsetSeconds] for initial fetch at a position, or
|
||||
/// [cursor] for pagination from a previous response.
|
||||
Future<GqlChatResponse> getVideoComments({
|
||||
required String videoId,
|
||||
int? contentOffsetSeconds,
|
||||
String? cursor,
|
||||
}) async {
|
||||
final headers = <String, String>{
|
||||
'Client-ID': twitchGqlMobileClientId,
|
||||
};
|
||||
|
||||
final variables = <String, dynamic>{
|
||||
'videoID': videoId,
|
||||
};
|
||||
if (cursor != null) {
|
||||
variables['cursor'] = cursor;
|
||||
} else {
|
||||
variables['contentOffsetSeconds'] = contentOffsetSeconds ?? 0;
|
||||
}
|
||||
|
||||
final body = {
|
||||
'operationName': 'VideoCommentsByOffsetOrCursor',
|
||||
'variables': variables,
|
||||
'extensions': {
|
||||
'persistedQuery': {
|
||||
'version': 1,
|
||||
'sha256Hash':
|
||||
'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
final response = await post<JsonMap>(
|
||||
'/gql',
|
||||
data: body,
|
||||
headers: headers,
|
||||
);
|
||||
|
||||
return GqlChatResponse.fromJson(response);
|
||||
}
|
||||
}
|
||||
|
|
@ -14,8 +14,9 @@ class UnauthorizedInterceptor extends Interceptor {
|
|||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
// Check if this is a 401 Unauthorized error
|
||||
if (err.response?.statusCode == 401) {
|
||||
// Check if this is a 401 Unauthorized error on a Twitch Helix/OAuth request
|
||||
if (err.response?.statusCode == 401 &&
|
||||
_isTwitchHelixOrOAuth(err.requestOptions.uri)) {
|
||||
// For token validation requests, let the error propagate so validateToken can handle it
|
||||
if (err.requestOptions.uri.path.endsWith('/validate')) {
|
||||
handler.next(err);
|
||||
|
|
@ -89,4 +90,10 @@ class UnauthorizedInterceptor extends Interceptor {
|
|||
}
|
||||
|
||||
static bool _isDialogShowing = false;
|
||||
|
||||
bool _isTwitchHelixOrOAuth(Uri uri) {
|
||||
final url = uri.toString();
|
||||
return url.startsWith('https://api.twitch.tv/helix') ||
|
||||
url.startsWith('https://id.twitch.tv/oauth2');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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<BTTVApi>.value(value: bttvApiService),
|
||||
Provider<FFZApi>.value(value: ffzApiService),
|
||||
Provider<SevenTVApi>.value(value: sevenTVApiService),
|
||||
Provider<TwitchGqlApi>.value(value: twitchGqlApiService),
|
||||
Provider<GlobalAssetsStore>.value(value: globalAssetsStore),
|
||||
],
|
||||
child: MyApp(firstRun: firstRun),
|
||||
|
|
|
|||
137
lib/models/gql_chat_comment.dart
Normal file
137
lib/models/gql_chat_comment.dart
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// Models for Twitch GQL VOD chat comments.
|
||||
//
|
||||
// Manually parsed (no codegen) because the GQL response uses camelCase
|
||||
// and deeply nested edges/node patterns.
|
||||
|
||||
class GqlChatEmote {
|
||||
final String emoteId;
|
||||
|
||||
const GqlChatEmote({required this.emoteId});
|
||||
|
||||
factory GqlChatEmote.fromJson(Map<String, dynamic> json) {
|
||||
return GqlChatEmote(emoteId: json['emoteID'] as String);
|
||||
}
|
||||
}
|
||||
|
||||
class GqlChatFragment {
|
||||
final String? text;
|
||||
final GqlChatEmote? emote;
|
||||
|
||||
const GqlChatFragment({this.text, this.emote});
|
||||
|
||||
factory GqlChatFragment.fromJson(Map<String, dynamic> json) {
|
||||
return GqlChatFragment(
|
||||
text: json['text'] as String?,
|
||||
emote: json['emote'] != null
|
||||
? GqlChatEmote.fromJson(json['emote'] as Map<String, dynamic>)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GqlChatBadge {
|
||||
final String setId;
|
||||
final String version;
|
||||
|
||||
const GqlChatBadge({required this.setId, required this.version});
|
||||
|
||||
factory GqlChatBadge.fromJson(Map<String, dynamic> json) {
|
||||
return GqlChatBadge(
|
||||
setId: json['setID'] as String,
|
||||
version: json['version'] as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GqlChatComment {
|
||||
final String id;
|
||||
final int contentOffsetSeconds;
|
||||
final String? commenterLogin;
|
||||
final String? commenterDisplayName;
|
||||
final String? commenterId;
|
||||
final String? userColor;
|
||||
final List<GqlChatBadge> userBadges;
|
||||
final List<GqlChatFragment> messageFragments;
|
||||
|
||||
const GqlChatComment({
|
||||
required this.id,
|
||||
required this.contentOffsetSeconds,
|
||||
this.commenterLogin,
|
||||
this.commenterDisplayName,
|
||||
this.commenterId,
|
||||
this.userColor,
|
||||
required this.userBadges,
|
||||
required this.messageFragments,
|
||||
});
|
||||
|
||||
factory GqlChatComment.fromGqlNode(Map<String, dynamic> node) {
|
||||
final commenter = node['commenter'] as Map<String, dynamic>?;
|
||||
final message = node['message'] as Map<String, dynamic>;
|
||||
|
||||
return GqlChatComment(
|
||||
id: node['id'] as String,
|
||||
contentOffsetSeconds: node['contentOffsetSeconds'] as int,
|
||||
commenterLogin: commenter?['login'] as String?,
|
||||
commenterDisplayName: commenter?['displayName'] as String?,
|
||||
commenterId: commenter?['id'] as String?,
|
||||
userColor: message['userColor'] as String?,
|
||||
userBadges: (message['userBadges'] as List)
|
||||
.map((b) => GqlChatBadge.fromJson(b as Map<String, dynamic>))
|
||||
.toList(),
|
||||
messageFragments: (message['fragments'] as List)
|
||||
.map((f) => GqlChatFragment.fromJson(f as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GqlChatResponse {
|
||||
final List<GqlChatComment> comments;
|
||||
final bool hasNextPage;
|
||||
final String? cursor;
|
||||
|
||||
const GqlChatResponse({
|
||||
required this.comments,
|
||||
required this.hasNextPage,
|
||||
this.cursor,
|
||||
});
|
||||
|
||||
factory GqlChatResponse.fromJson(Map<String, dynamic> json) {
|
||||
// GQL returns 200 even on errors — check for errors array.
|
||||
if (json['errors'] != null) {
|
||||
final errors = json['errors'] as List;
|
||||
final message = errors.isNotEmpty
|
||||
? (errors.first as Map<String, dynamic>)['message'] as String?
|
||||
: 'Unknown GQL error';
|
||||
throw Exception('GQL error: $message');
|
||||
}
|
||||
|
||||
final video = json['data']?['video'] as Map<String, dynamic>?;
|
||||
if (video == null) {
|
||||
return const GqlChatResponse(
|
||||
comments: [],
|
||||
hasNextPage: false,
|
||||
);
|
||||
}
|
||||
|
||||
final commentsData = video['comments'] as Map<String, dynamic>;
|
||||
final edges = commentsData['edges'] as List;
|
||||
final pageInfo = commentsData['pageInfo'] as Map<String, dynamic>;
|
||||
|
||||
final comments = edges
|
||||
.map((edge) => GqlChatComment.fromGqlNode(
|
||||
(edge as Map<String, dynamic>)['node'] as Map<String, dynamic>,
|
||||
))
|
||||
.toList();
|
||||
|
||||
final cursor = edges.isNotEmpty
|
||||
? (edges.last as Map<String, dynamic>)['cursor'] as String?
|
||||
: null;
|
||||
|
||||
return GqlChatResponse(
|
||||
comments: comments,
|
||||
hasNextPage: pageInfo['hasNextPage'] as bool,
|
||||
cursor: cursor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = <String, String>{
|
||||
'display-name': comment.commenterDisplayName ?? 'Deleted User',
|
||||
'user-id': comment.commenterId ?? '',
|
||||
'id': comment.id,
|
||||
'tmi-sent-ts': '${comment.contentOffsetSeconds * 1000}',
|
||||
if (comment.userColor != null) 'color': comment.userColor!,
|
||||
if (comment.userBadges.isNotEmpty)
|
||||
'badges': comment.userBadges
|
||||
.map((b) => '${b.setId}/${b.version}')
|
||||
.join(','),
|
||||
};
|
||||
|
||||
// Reconstruct message text from fragments.
|
||||
final messageText = comment.messageFragments
|
||||
.map((f) => f.text ?? '')
|
||||
.join();
|
||||
|
||||
// Build local emotes map from fragments that have Twitch emotes.
|
||||
// generateSpan() will match these by name in the split words.
|
||||
final localEmotes = <String, Emote>{};
|
||||
for (final fragment in comment.messageFragments) {
|
||||
if (fragment.emote != null && fragment.text != null) {
|
||||
localEmotes[fragment.text!] = Emote(
|
||||
name: fragment.text!,
|
||||
zeroWidth: false,
|
||||
url:
|
||||
'$_twitchEmoteBaseUrl/${fragment.emote!.emoteId}$_emoteModeSuffix',
|
||||
type: EmoteType.twitchSub,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return IRCMessage(
|
||||
raw: '',
|
||||
command: Command.privateMessage,
|
||||
tags: tags,
|
||||
user: comment.commenterLogin,
|
||||
localEmotes: localEmotes.isNotEmpty ? localEmotes : null,
|
||||
message: messageText,
|
||||
split: messageText.split(' ').where((w) => w.isNotEmpty).toList(),
|
||||
action: false,
|
||||
mention: false,
|
||||
);
|
||||
}
|
||||
|
||||
factory IRCMessage.createNotice({
|
||||
required String message,
|
||||
VoidCallback? actionCallback,
|
||||
|
|
|
|||
24
lib/models/playback_access_token.dart
Normal file
24
lib/models/playback_access_token.dart
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
class PlaybackAccessToken {
|
||||
final String value;
|
||||
final String signature;
|
||||
|
||||
const PlaybackAccessToken({
|
||||
required this.value,
|
||||
required this.signature,
|
||||
});
|
||||
|
||||
factory PlaybackAccessToken.fromGqlResponse(Map<String, dynamic> json) {
|
||||
final data =
|
||||
(json['data'] as Map<String, dynamic>?)?['streamPlaybackAccessToken']
|
||||
as Map<String, dynamic>?;
|
||||
if (data == null) {
|
||||
throw const FormatException(
|
||||
'streamPlaybackAccessToken missing from GQL response',
|
||||
);
|
||||
}
|
||||
return PlaybackAccessToken(
|
||||
value: data['value'] as String,
|
||||
signature: data['signature'] as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
60
lib/models/video.dart
Normal file
60
lib/models/video.dart
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'video.g.dart';
|
||||
|
||||
/// Object for Twitch VODs (past broadcasts).
|
||||
@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
|
||||
class VideoTwitch {
|
||||
final String id;
|
||||
final String? streamId;
|
||||
final String userId;
|
||||
final String userLogin;
|
||||
final String userName;
|
||||
final String title;
|
||||
final String createdAt;
|
||||
final String publishedAt;
|
||||
final String duration;
|
||||
final String thumbnailUrl;
|
||||
final int viewCount;
|
||||
|
||||
const VideoTwitch(
|
||||
this.id,
|
||||
this.streamId,
|
||||
this.userId,
|
||||
this.userLogin,
|
||||
this.userName,
|
||||
this.title,
|
||||
this.createdAt,
|
||||
this.publishedAt,
|
||||
this.duration,
|
||||
this.thumbnailUrl,
|
||||
this.viewCount,
|
||||
);
|
||||
|
||||
factory VideoTwitch.fromJson(Map<String, dynamic> json) =>
|
||||
_$VideoTwitchFromJson(json);
|
||||
}
|
||||
|
||||
@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
|
||||
class VideosTwitch {
|
||||
final List<VideoTwitch> data;
|
||||
final Map<String, String> pagination;
|
||||
|
||||
const VideosTwitch(this.data, this.pagination);
|
||||
|
||||
factory VideosTwitch.fromJson(Map<String, dynamic> json) =>
|
||||
_$VideosTwitchFromJson(json);
|
||||
}
|
||||
|
||||
/// Parses a Twitch duration string like "3h26m15s" into a [Duration].
|
||||
Duration parseTwitchDuration(String duration) {
|
||||
final regex = RegExp(r'(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?');
|
||||
final match = regex.firstMatch(duration);
|
||||
if (match == null) return Duration.zero;
|
||||
|
||||
final hours = int.tryParse(match.group(1) ?? '') ?? 0;
|
||||
final minutes = int.tryParse(match.group(2) ?? '') ?? 0;
|
||||
final seconds = int.tryParse(match.group(3) ?? '') ?? 0;
|
||||
|
||||
return Duration(hours: hours, minutes: minutes, seconds: seconds);
|
||||
}
|
||||
28
lib/models/video.g.dart
Normal file
28
lib/models/video.g.dart
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'video.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
VideoTwitch _$VideoTwitchFromJson(Map<String, dynamic> json) => VideoTwitch(
|
||||
json['id'] as String,
|
||||
json['stream_id'] as String?,
|
||||
json['user_id'] as String,
|
||||
json['user_login'] as String,
|
||||
json['user_name'] as String,
|
||||
json['title'] as String,
|
||||
json['created_at'] as String,
|
||||
json['published_at'] as String,
|
||||
json['duration'] as String,
|
||||
json['thumbnail_url'] as String,
|
||||
(json['view_count'] as num).toInt(),
|
||||
);
|
||||
|
||||
VideosTwitch _$VideosTwitchFromJson(Map<String, dynamic> json) => VideosTwitch(
|
||||
(json['data'] as List<dynamic>)
|
||||
.map((e) => VideoTwitch.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
Map<String, String>.from(json['pagination'] as Map),
|
||||
);
|
||||
|
|
@ -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<VideoChat>
|
|||
primaryDisplayName: widget.userName,
|
||||
);
|
||||
|
||||
late final VideoStore _videoStore = VideoStore(
|
||||
userLogin: widget.userLogin,
|
||||
userId: widget.userId,
|
||||
twitchApi: context.twitchApi,
|
||||
authStore: context.authStore,
|
||||
settingsStore: context.settingsStore,
|
||||
);
|
||||
late final VideoPlayerInterface _videoStore =
|
||||
context.settingsStore.useNativePlayer
|
||||
? NativeVideoStore(
|
||||
userLogin: widget.userLogin,
|
||||
userId: widget.userId,
|
||||
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<VideoChat>
|
|||
|
||||
final player = GestureDetector(
|
||||
onLongPress: _videoStore.handleToggleOverlay,
|
||||
child: Video(key: _videoKey, videoStore: _videoStore),
|
||||
child: _videoStore is NativeVideoStore
|
||||
? NativeVideo(nativeVideoStore: _videoStore)
|
||||
: Video(
|
||||
key: _videoKey,
|
||||
videoStore: _videoStore as VideoStore,
|
||||
),
|
||||
);
|
||||
|
||||
final overlay = GestureDetector(
|
||||
|
|
@ -318,6 +336,10 @@ class _VideoChatState extends State<VideoChat>
|
|||
},
|
||||
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<VideoChat>
|
|||
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<VideoChat>
|
|||
if (settingsStore.showVideo) ...[
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Container(), // Placeholder for video space
|
||||
child: SizedBox(),
|
||||
),
|
||||
],
|
||||
Expanded(child: chat),
|
||||
|
|
|
|||
18
lib/screens/channel/chat/chat_render_context.dart
Normal file
18
lib/screens/channel/chat/chat_render_context.dart
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
import 'package:mobx/mobx.dart';
|
||||
|
||||
/// Minimal interface for rendering chat messages.
|
||||
///
|
||||
/// Implemented by both [ChatStore] (live chat) and [VodChatStore] (VOD replay)
|
||||
/// so that [ChatMessage] can render messages from either source.
|
||||
abstract class ChatRenderContext {
|
||||
ChatAssetsStore get assetsStore;
|
||||
AuthStore get auth;
|
||||
SettingsStore get settings;
|
||||
String get channelId;
|
||||
ObservableSet<String> get revealedMessageIds;
|
||||
void revealMessage(String id);
|
||||
void updateNotification(String message);
|
||||
}
|
||||
|
|
@ -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<ChatDetails> {
|
|||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.video_library_rounded),
|
||||
title: const Text('Past broadcasts'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => VodList(
|
||||
userId: widget.chatStore.channelId,
|
||||
displayName: widget.chatStore.displayName,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add_comment_rounded),
|
||||
title: const Text('Add chat'),
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ class ChatModes extends StatelessWidget {
|
|||
style: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
).colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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<String>();
|
||||
|
||||
@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
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
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<void> 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
62
lib/screens/channel/video/native_video.dart
Normal file
62
lib/screens/channel/video/native_video.dart
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import 'package:better_native_video_player/better_native_video_player.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:frosty/screens/channel/video/native_video_store.dart';
|
||||
|
||||
/// Native video player widget backed by AVPlayerViewController (iOS)
|
||||
/// and ExoPlayer (Android).
|
||||
class NativeVideo extends StatelessWidget {
|
||||
final NativeVideoStore nativeVideoStore;
|
||||
|
||||
const NativeVideo({super.key, required this.nativeVideoStore});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Observer(
|
||||
builder: (context) {
|
||||
final controller = nativeVideoStore.controller;
|
||||
final error = nativeVideoStore.error;
|
||||
|
||||
final isOffline =
|
||||
!nativeVideoStore.loading &&
|
||||
nativeVideoStore.streamInfo == null &&
|
||||
error == null;
|
||||
|
||||
return ColoredBox(
|
||||
color: Colors.black,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (controller != null && !isOffline)
|
||||
NativeVideoPlayer(
|
||||
key: ObjectKey(controller),
|
||||
controller: controller,
|
||||
),
|
||||
if (error != null)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
error,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isOffline)
|
||||
const Center(
|
||||
child: Text(
|
||||
'Offline',
|
||||
style: TextStyle(
|
||||
color: Colors.white38,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
535
lib/screens/channel/video/native_video_store.dart
Normal file
535
lib/screens/channel/video/native_video_store.dart
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:better_native_video_player/better_native_video_player.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:frosty/apis/twitch_api.dart';
|
||||
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||
import 'package:frosty/constants.dart';
|
||||
import 'package:frosty/models/channel.dart';
|
||||
import 'package:frosty/models/playback_access_token.dart';
|
||||
import 'package:frosty/models/stream.dart';
|
||||
import 'package:frosty/screens/channel/video/video_player_interface.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
import 'package:mobx/mobx.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:simple_pip_mode/simple_pip.dart';
|
||||
|
||||
part 'native_video_store.g.dart';
|
||||
|
||||
class NativeVideoStore = NativeVideoStoreBase with _$NativeVideoStore;
|
||||
|
||||
abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface {
|
||||
static int _nextId = 0;
|
||||
|
||||
final String userLogin;
|
||||
final String userId;
|
||||
final TwitchApi twitchApi;
|
||||
final TwitchGqlApi twitchGqlApi;
|
||||
final AuthStore authStore;
|
||||
|
||||
@override
|
||||
final SettingsStore settingsStore;
|
||||
|
||||
@readonly
|
||||
NativeVideoPlayerController? _controller;
|
||||
|
||||
Timer? _overlayTimer;
|
||||
Timer? _latencyTimer;
|
||||
Timer? _stallRecoveryTimer;
|
||||
var _stallRecoveryAttempt = 0;
|
||||
DateTime? _lastStreamInfoUpdate;
|
||||
Future<void>? _streamInfoRequest;
|
||||
bool _overlayWasVisibleBeforePip = true;
|
||||
var _userPaused = false;
|
||||
|
||||
List<NativeVideoPlayerQuality> _qualityObjects = [];
|
||||
var _firstTimeSettingQuality = true;
|
||||
int? _pendingQualityIndex;
|
||||
var _disposed = false;
|
||||
|
||||
final _pip = SimplePip();
|
||||
ReactionDisposer? _disposeAndroidAutoPipReaction;
|
||||
|
||||
StreamSubscription<bool>? _pipSub;
|
||||
StreamSubscription<List<NativeVideoPlayerQuality>>? _qualitiesSub;
|
||||
|
||||
@readonly
|
||||
var _loading = true;
|
||||
|
||||
@readonly
|
||||
var _paused = true;
|
||||
|
||||
@readonly
|
||||
var _overlayVisible = true;
|
||||
|
||||
@readonly
|
||||
StreamTwitch? _streamInfo;
|
||||
|
||||
@readonly
|
||||
Channel? _offlineChannelInfo;
|
||||
|
||||
@readonly
|
||||
var _availableStreamQualities = <String>[];
|
||||
|
||||
@readonly
|
||||
var _streamQualityIndex = 0;
|
||||
|
||||
@override
|
||||
String get streamQuality =>
|
||||
_availableStreamQualities.elementAtOrNull(_streamQualityIndex) ?? 'Auto';
|
||||
|
||||
@readonly
|
||||
String? _latency;
|
||||
|
||||
@readonly
|
||||
var _isInPipMode = false;
|
||||
|
||||
@readonly
|
||||
String? _error;
|
||||
|
||||
String? _hlsUrl;
|
||||
|
||||
NativeVideoStoreBase({
|
||||
required this.userLogin,
|
||||
required this.userId,
|
||||
required this.twitchApi,
|
||||
required this.twitchGqlApi,
|
||||
required this.authStore,
|
||||
required this.settingsStore,
|
||||
}) {
|
||||
_controller = NativeVideoPlayerController(
|
||||
id: _nextId++,
|
||||
autoPlay: true,
|
||||
showNativeControls: false,
|
||||
);
|
||||
_controller!.addActivityListener(_handleActivityEvent);
|
||||
_scheduleOverlayHide();
|
||||
updateStreamInfo();
|
||||
_initPlayer();
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
_disposeAndroidAutoPipReaction = autorun((_) async {
|
||||
if (settingsStore.showVideo && await SimplePip.isAutoPipAvailable) {
|
||||
_pip.setAutoPipMode();
|
||||
} else {
|
||||
_pip.setAutoPipMode(autoEnter: false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
Future<void> _initPlayer() async {
|
||||
try {
|
||||
// Use web cookie token (works with web Client-ID) for ad-free playback.
|
||||
final authToken = authStore.gqlToken;
|
||||
late final PlaybackAccessToken token;
|
||||
try {
|
||||
token = await twitchGqlApi.getPlaybackAccessToken(
|
||||
login: userLogin,
|
||||
authToken: authToken,
|
||||
);
|
||||
} catch (_) {
|
||||
if (authToken != null) {
|
||||
token = await twitchGqlApi.getPlaybackAccessToken(
|
||||
login: userLogin,
|
||||
);
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
if (_disposed) return;
|
||||
|
||||
_hlsUrl = twitchGqlApi.buildHlsUrl(login: userLogin, token: token);
|
||||
|
||||
await _controller!.initialize();
|
||||
if (_disposed) return;
|
||||
|
||||
_pipSub = _controller!.isPipEnabledStream.listen((isPip) {
|
||||
runInAction(() {
|
||||
if (isPip && !_isInPipMode) {
|
||||
_overlayWasVisibleBeforePip = _overlayVisible;
|
||||
_isInPipMode = true;
|
||||
_overlayTimer?.cancel();
|
||||
_overlayVisible = true;
|
||||
} else if (!isPip && _isInPipMode) {
|
||||
_isInPipMode = false;
|
||||
if (_overlayWasVisibleBeforePip) {
|
||||
_scheduleOverlayHide();
|
||||
} else {
|
||||
_overlayVisible = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
_qualitiesSub = _controller!.qualitiesStream.listen((qualities) {
|
||||
runInAction(() {
|
||||
// Filter out the plugin's auto entry (we add our own) and deduplicate.
|
||||
final seen = <String>{};
|
||||
final filtered = qualities
|
||||
.where((q) => !q.isAuto && seen.add(q.label))
|
||||
.toList();
|
||||
|
||||
// Reverse so highest quality comes first.
|
||||
_qualityObjects = filtered.reversed.toList();
|
||||
_availableStreamQualities = [
|
||||
'Auto',
|
||||
..._qualityObjects.map((q) => q.label),
|
||||
];
|
||||
|
||||
if (_firstTimeSettingQuality && qualities.isNotEmpty) {
|
||||
_firstTimeSettingQuality = false;
|
||||
if (settingsStore.defaultToHighestQuality) {
|
||||
_pendingQualityIndex = 1;
|
||||
} else {
|
||||
SharedPreferences.getInstance().then((prefs) {
|
||||
if (_disposed) return;
|
||||
final lastQuality = prefs.getString(kLastStreamQualityKey);
|
||||
if (lastQuality != null) {
|
||||
final index = _availableStreamQualities.indexOf(lastQuality);
|
||||
if (index != -1) _pendingQualityIndex = index;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await _controller!.loadUrl(url: _hlsUrl!);
|
||||
await _controller!.configureForLivePlayback();
|
||||
|
||||
_startLatencyPolling();
|
||||
|
||||
runInAction(() {
|
||||
_error = null;
|
||||
});
|
||||
} catch (e) {
|
||||
if (_disposed) return;
|
||||
// Wait for stream info so we can distinguish "offline" from "broken".
|
||||
await updateStreamInfo(forceUpdate: true);
|
||||
if (_disposed) return;
|
||||
runInAction(() {
|
||||
_loading = false;
|
||||
// Only show the error if the stream is actually live — an offline
|
||||
// channel is expected to fail here and the overlay handles it.
|
||||
if (_streamInfo != null) {
|
||||
_error =
|
||||
'Native player failed to load. Try the standard player in Settings.';
|
||||
}
|
||||
});
|
||||
debugPrint('NativeVideoStore init error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _handleActivityEvent(PlayerActivityEvent event) {
|
||||
runInAction(() {
|
||||
switch (event.state) {
|
||||
case PlayerActivityState.playing:
|
||||
_loading = false;
|
||||
_paused = false;
|
||||
_stallRecoveryTimer?.cancel();
|
||||
_stallRecoveryAttempt = 0;
|
||||
if (_pendingQualityIndex != null) {
|
||||
final index = _pendingQualityIndex!;
|
||||
_pendingQualityIndex = null;
|
||||
_setStreamQualityIndex(index);
|
||||
}
|
||||
case PlayerActivityState.buffering:
|
||||
case PlayerActivityState.loading:
|
||||
_loading = true;
|
||||
if (!_userPaused) _startStallRecoveryTimer();
|
||||
case PlayerActivityState.error:
|
||||
_loading = false;
|
||||
_stallRecoveryTimer?.cancel();
|
||||
_error = event.data?['message'] as String? ??
|
||||
'Playback error. Try refreshing or switch to the standard player in Settings.';
|
||||
case PlayerActivityState.paused:
|
||||
case PlayerActivityState.stopped:
|
||||
case PlayerActivityState.completed:
|
||||
case PlayerActivityState.idle:
|
||||
_paused = true;
|
||||
_stallRecoveryTimer?.cancel();
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _startStallRecoveryTimer() {
|
||||
_stallRecoveryTimer?.cancel();
|
||||
_stallRecoveryTimer = Timer(const Duration(seconds: 8), () {
|
||||
if (_disposed || !_loading || _userPaused) return;
|
||||
_stallRecoveryAttempt++;
|
||||
if (_stallRecoveryAttempt <= 1) {
|
||||
// Light recovery: seek to live edge and resume.
|
||||
debugPrint('NativeVideoStore: stall detected, seeking to live edge');
|
||||
_controller?.seekToLiveEdge();
|
||||
_controller?.play();
|
||||
_startStallRecoveryTimer();
|
||||
} else {
|
||||
// Heavy recovery: new HLS token + full player restart.
|
||||
debugPrint('NativeVideoStore: stall persists, refreshing player');
|
||||
handleRefresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _startLatencyPolling() {
|
||||
_latencyTimer?.cancel();
|
||||
if (settingsStore.autoSyncChatDelay) {
|
||||
settingsStore.syncedChatDelay = 0;
|
||||
}
|
||||
_pollLatency();
|
||||
_latencyTimer = Timer.periodic(
|
||||
const Duration(seconds: 10),
|
||||
(_) => _pollLatency(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pollLatency() async {
|
||||
final seconds = await _controller?.getLatencyToLive();
|
||||
if (seconds == null) return;
|
||||
final rounded = seconds.round();
|
||||
|
||||
runInAction(() {
|
||||
_latency = '${rounded}s';
|
||||
});
|
||||
|
||||
if (!settingsStore.autoSyncChatDelay) return;
|
||||
|
||||
// Only update when unset or drifted by >2s to avoid restarting
|
||||
// the chat countdown on minor fluctuations.
|
||||
final current = settingsStore.syncedChatDelay;
|
||||
if (current == 0 || (seconds - current).abs() > 2) {
|
||||
settingsStore.syncedChatDelay = seconds;
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleOverlayHide([Duration delay = const Duration(seconds: 5)]) {
|
||||
_overlayTimer?.cancel();
|
||||
|
||||
if (_isInPipMode) {
|
||||
_overlayVisible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_overlayTimer = Timer(delay, () {
|
||||
if (_isInPipMode) return;
|
||||
runInAction(() {
|
||||
_overlayVisible = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@action
|
||||
void handleVideoTap() {
|
||||
if (_isInPipMode) {
|
||||
_overlayVisible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_overlayTimer?.cancel();
|
||||
|
||||
if (_overlayVisible) {
|
||||
_overlayVisible = false;
|
||||
} else {
|
||||
updateStreamInfo(forceUpdate: true);
|
||||
_overlayVisible = true;
|
||||
_scheduleOverlayHide();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void handlePausePlay() {
|
||||
if (_controller == null) return;
|
||||
if (_paused) {
|
||||
_userPaused = false;
|
||||
_controller!.play();
|
||||
} else {
|
||||
_userPaused = true;
|
||||
_controller!.pause();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@action
|
||||
void handleToggleOverlay() {
|
||||
if (settingsStore.toggleableOverlay) {
|
||||
HapticFeedback.mediumImpact();
|
||||
settingsStore.showOverlay = !settingsStore.showOverlay;
|
||||
|
||||
if (settingsStore.showOverlay) {
|
||||
_overlayVisible = true;
|
||||
_scheduleOverlayHide(const Duration(seconds: 3));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@action
|
||||
Future<void> handleRefresh() async {
|
||||
HapticFeedback.lightImpact();
|
||||
_loading = true;
|
||||
_paused = true;
|
||||
_userPaused = false;
|
||||
_stallRecoveryAttempt = 0;
|
||||
_firstTimeSettingQuality = true;
|
||||
_pendingQualityIndex = null;
|
||||
_isInPipMode = false;
|
||||
_availableStreamQualities = [];
|
||||
_qualityObjects = [];
|
||||
_error = null;
|
||||
|
||||
_latencyTimer?.cancel();
|
||||
_stallRecoveryTimer?.cancel();
|
||||
_pipSub?.cancel();
|
||||
_qualitiesSub?.cancel();
|
||||
_controller?.removeActivityListener(_handleActivityEvent);
|
||||
_controller?.dispose();
|
||||
_controller = NativeVideoPlayerController(
|
||||
id: _nextId++,
|
||||
autoPlay: true,
|
||||
showNativeControls: false,
|
||||
);
|
||||
_controller!.addActivityListener(_handleActivityEvent);
|
||||
|
||||
_initPlayer();
|
||||
updateStreamInfo();
|
||||
}
|
||||
|
||||
@override
|
||||
void requestPictureInPicture() {
|
||||
if (Platform.isAndroid) {
|
||||
_pip.enterPipMode(autoEnter: true);
|
||||
} else {
|
||||
_controller?.enterPictureInPicture();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@action
|
||||
void togglePictureInPicture() {
|
||||
if (Platform.isIOS && _isInPipMode) {
|
||||
_controller?.exitPictureInPicture();
|
||||
} else {
|
||||
requestPictureInPicture();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@action
|
||||
Future<void> updateStreamQualities() async {
|
||||
// Qualities are populated automatically via qualitiesStream.
|
||||
}
|
||||
|
||||
@override
|
||||
@action
|
||||
Future<void> setStreamQuality(String quality) async {
|
||||
final index = _availableStreamQualities.indexOf(quality);
|
||||
if (index == -1) return;
|
||||
await _setStreamQualityIndex(index);
|
||||
}
|
||||
|
||||
@action
|
||||
Future<void> _setStreamQualityIndex(int index) async {
|
||||
_streamQualityIndex = index;
|
||||
if (_controller == null) return;
|
||||
|
||||
if (index == 0) {
|
||||
// 'Auto' — reset to adaptive quality
|
||||
await _controller!.setQuality(NativeVideoPlayerQuality.auto());
|
||||
} else {
|
||||
final qualityIndex = index - 1;
|
||||
if (qualityIndex < _qualityObjects.length) {
|
||||
await _controller!.setQuality(_qualityObjects[qualityIndex]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@action
|
||||
Future<void> updateStreamInfo({bool forceUpdate = false}) async {
|
||||
if (_streamInfoRequest != null) {
|
||||
await _streamInfoRequest;
|
||||
return;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
if (!forceUpdate && _lastStreamInfoUpdate != null) {
|
||||
final timeSince = now.difference(_lastStreamInfoUpdate!);
|
||||
if (timeSince.inSeconds < 5) return;
|
||||
}
|
||||
|
||||
_lastStreamInfoUpdate = now;
|
||||
|
||||
final request = _updateStreamInfoInternal();
|
||||
_streamInfoRequest = request;
|
||||
|
||||
try {
|
||||
await request;
|
||||
} finally {
|
||||
if (identical(_streamInfoRequest, request)) {
|
||||
_streamInfoRequest = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updateStreamInfoInternal() async {
|
||||
try {
|
||||
final info = await twitchApi.getStream(userLogin: userLogin);
|
||||
runInAction(() {
|
||||
_streamInfo = info;
|
||||
_offlineChannelInfo = null;
|
||||
});
|
||||
} catch (e) {
|
||||
Channel? channel;
|
||||
try {
|
||||
channel = await twitchApi.getChannel(userId: userId);
|
||||
} catch (_) {}
|
||||
|
||||
runInAction(() {
|
||||
_overlayTimer?.cancel();
|
||||
_streamInfo = null;
|
||||
_offlineChannelInfo = channel;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@action
|
||||
void handleAppResume() {
|
||||
updateStreamInfo(forceUpdate: true);
|
||||
if (!_userPaused && _controller != null) {
|
||||
_controller!.play();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@action
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
SimplePip.isAutoPipAvailable.then((isAutoPipAvailable) {
|
||||
if (isAutoPipAvailable) _pip.setAutoPipMode(autoEnter: false);
|
||||
});
|
||||
}
|
||||
|
||||
_overlayTimer?.cancel();
|
||||
_latencyTimer?.cancel();
|
||||
_stallRecoveryTimer?.cancel();
|
||||
_pipSub?.cancel();
|
||||
_qualitiesSub?.cancel();
|
||||
_disposeAndroidAutoPipReaction?.call();
|
||||
|
||||
_controller?.removeActivityListener(_handleActivityEvent);
|
||||
_controller?.dispose();
|
||||
_controller = null;
|
||||
}
|
||||
}
|
||||
375
lib/screens/channel/video/native_video_store.g.dart
Normal file
375
lib/screens/channel/video/native_video_store.g.dart
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'native_video_store.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// StoreGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
|
||||
|
||||
mixin _$NativeVideoStore on NativeVideoStoreBase, Store {
|
||||
late final _$_controllerAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._controller',
|
||||
context: context,
|
||||
);
|
||||
|
||||
NativeVideoPlayerController? get controller {
|
||||
_$_controllerAtom.reportRead();
|
||||
return super._controller;
|
||||
}
|
||||
|
||||
@override
|
||||
NativeVideoPlayerController? get _controller => controller;
|
||||
|
||||
@override
|
||||
set _controller(NativeVideoPlayerController? value) {
|
||||
_$_controllerAtom.reportWrite(value, super._controller, () {
|
||||
super._controller = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_loadingAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._loading',
|
||||
context: context,
|
||||
);
|
||||
|
||||
bool get loading {
|
||||
_$_loadingAtom.reportRead();
|
||||
return super._loading;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get _loading => loading;
|
||||
|
||||
@override
|
||||
set _loading(bool value) {
|
||||
_$_loadingAtom.reportWrite(value, super._loading, () {
|
||||
super._loading = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_pausedAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._paused',
|
||||
context: context,
|
||||
);
|
||||
|
||||
bool get paused {
|
||||
_$_pausedAtom.reportRead();
|
||||
return super._paused;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get _paused => paused;
|
||||
|
||||
@override
|
||||
set _paused(bool value) {
|
||||
_$_pausedAtom.reportWrite(value, super._paused, () {
|
||||
super._paused = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_overlayVisibleAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._overlayVisible',
|
||||
context: context,
|
||||
);
|
||||
|
||||
bool get overlayVisible {
|
||||
_$_overlayVisibleAtom.reportRead();
|
||||
return super._overlayVisible;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get _overlayVisible => overlayVisible;
|
||||
|
||||
@override
|
||||
set _overlayVisible(bool value) {
|
||||
_$_overlayVisibleAtom.reportWrite(value, super._overlayVisible, () {
|
||||
super._overlayVisible = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_streamInfoAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._streamInfo',
|
||||
context: context,
|
||||
);
|
||||
|
||||
StreamTwitch? get streamInfo {
|
||||
_$_streamInfoAtom.reportRead();
|
||||
return super._streamInfo;
|
||||
}
|
||||
|
||||
@override
|
||||
StreamTwitch? get _streamInfo => streamInfo;
|
||||
|
||||
@override
|
||||
set _streamInfo(StreamTwitch? value) {
|
||||
_$_streamInfoAtom.reportWrite(value, super._streamInfo, () {
|
||||
super._streamInfo = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_offlineChannelInfoAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._offlineChannelInfo',
|
||||
context: context,
|
||||
);
|
||||
|
||||
Channel? get offlineChannelInfo {
|
||||
_$_offlineChannelInfoAtom.reportRead();
|
||||
return super._offlineChannelInfo;
|
||||
}
|
||||
|
||||
@override
|
||||
Channel? get _offlineChannelInfo => offlineChannelInfo;
|
||||
|
||||
@override
|
||||
set _offlineChannelInfo(Channel? value) {
|
||||
_$_offlineChannelInfoAtom.reportWrite(value, super._offlineChannelInfo, () {
|
||||
super._offlineChannelInfo = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_availableStreamQualitiesAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._availableStreamQualities',
|
||||
context: context,
|
||||
);
|
||||
|
||||
List<String> get availableStreamQualities {
|
||||
_$_availableStreamQualitiesAtom.reportRead();
|
||||
return super._availableStreamQualities;
|
||||
}
|
||||
|
||||
@override
|
||||
List<String> get _availableStreamQualities => availableStreamQualities;
|
||||
|
||||
@override
|
||||
set _availableStreamQualities(List<String> value) {
|
||||
_$_availableStreamQualitiesAtom.reportWrite(
|
||||
value,
|
||||
super._availableStreamQualities,
|
||||
() {
|
||||
super._availableStreamQualities = value;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
late final _$_streamQualityIndexAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._streamQualityIndex',
|
||||
context: context,
|
||||
);
|
||||
|
||||
int get streamQualityIndex {
|
||||
_$_streamQualityIndexAtom.reportRead();
|
||||
return super._streamQualityIndex;
|
||||
}
|
||||
|
||||
@override
|
||||
int get _streamQualityIndex => streamQualityIndex;
|
||||
|
||||
@override
|
||||
set _streamQualityIndex(int value) {
|
||||
_$_streamQualityIndexAtom.reportWrite(value, super._streamQualityIndex, () {
|
||||
super._streamQualityIndex = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_latencyAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._latency',
|
||||
context: context,
|
||||
);
|
||||
|
||||
String? get latency {
|
||||
_$_latencyAtom.reportRead();
|
||||
return super._latency;
|
||||
}
|
||||
|
||||
@override
|
||||
String? get _latency => latency;
|
||||
|
||||
@override
|
||||
set _latency(String? value) {
|
||||
_$_latencyAtom.reportWrite(value, super._latency, () {
|
||||
super._latency = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_isInPipModeAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._isInPipMode',
|
||||
context: context,
|
||||
);
|
||||
|
||||
bool get isInPipMode {
|
||||
_$_isInPipModeAtom.reportRead();
|
||||
return super._isInPipMode;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get _isInPipMode => isInPipMode;
|
||||
|
||||
@override
|
||||
set _isInPipMode(bool value) {
|
||||
_$_isInPipModeAtom.reportWrite(value, super._isInPipMode, () {
|
||||
super._isInPipMode = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_errorAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._error',
|
||||
context: context,
|
||||
);
|
||||
|
||||
String? get error {
|
||||
_$_errorAtom.reportRead();
|
||||
return super._error;
|
||||
}
|
||||
|
||||
@override
|
||||
String? get _error => error;
|
||||
|
||||
@override
|
||||
set _error(String? value) {
|
||||
_$_errorAtom.reportWrite(value, super._error, () {
|
||||
super._error = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_initPlayerAsyncAction = AsyncAction(
|
||||
'NativeVideoStoreBase._initPlayer',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> _initPlayer() {
|
||||
return _$_initPlayerAsyncAction.run(() => super._initPlayer());
|
||||
}
|
||||
|
||||
late final _$handleRefreshAsyncAction = AsyncAction(
|
||||
'NativeVideoStoreBase.handleRefresh',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> handleRefresh() {
|
||||
return _$handleRefreshAsyncAction.run(() => super.handleRefresh());
|
||||
}
|
||||
|
||||
late final _$updateStreamQualitiesAsyncAction = AsyncAction(
|
||||
'NativeVideoStoreBase.updateStreamQualities',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> updateStreamQualities() {
|
||||
return _$updateStreamQualitiesAsyncAction.run(
|
||||
() => super.updateStreamQualities(),
|
||||
);
|
||||
}
|
||||
|
||||
late final _$setStreamQualityAsyncAction = AsyncAction(
|
||||
'NativeVideoStoreBase.setStreamQuality',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> setStreamQuality(String quality) {
|
||||
return _$setStreamQualityAsyncAction.run(
|
||||
() => super.setStreamQuality(quality),
|
||||
);
|
||||
}
|
||||
|
||||
late final _$_setStreamQualityIndexAsyncAction = AsyncAction(
|
||||
'NativeVideoStoreBase._setStreamQualityIndex',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> _setStreamQualityIndex(int index) {
|
||||
return _$_setStreamQualityIndexAsyncAction.run(
|
||||
() => super._setStreamQualityIndex(index),
|
||||
);
|
||||
}
|
||||
|
||||
late final _$updateStreamInfoAsyncAction = AsyncAction(
|
||||
'NativeVideoStoreBase.updateStreamInfo',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> updateStreamInfo({bool forceUpdate = false}) {
|
||||
return _$updateStreamInfoAsyncAction.run(
|
||||
() => super.updateStreamInfo(forceUpdate: forceUpdate),
|
||||
);
|
||||
}
|
||||
|
||||
late final _$NativeVideoStoreBaseActionController = ActionController(
|
||||
name: 'NativeVideoStoreBase',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
void handleVideoTap() {
|
||||
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
|
||||
name: 'NativeVideoStoreBase.handleVideoTap',
|
||||
);
|
||||
try {
|
||||
return super.handleVideoTap();
|
||||
} finally {
|
||||
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void handleToggleOverlay() {
|
||||
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
|
||||
name: 'NativeVideoStoreBase.handleToggleOverlay',
|
||||
);
|
||||
try {
|
||||
return super.handleToggleOverlay();
|
||||
} finally {
|
||||
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void togglePictureInPicture() {
|
||||
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
|
||||
name: 'NativeVideoStoreBase.togglePictureInPicture',
|
||||
);
|
||||
try {
|
||||
return super.togglePictureInPicture();
|
||||
} finally {
|
||||
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void handleAppResume() {
|
||||
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
|
||||
name: 'NativeVideoStoreBase.handleAppResume',
|
||||
);
|
||||
try {
|
||||
return super.handleAppResume();
|
||||
} finally {
|
||||
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction(
|
||||
name: 'NativeVideoStoreBase.dispose',
|
||||
);
|
||||
try {
|
||||
return super.dispose();
|
||||
} finally {
|
||||
_$NativeVideoStoreBaseActionController.endAction(_$actionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''
|
||||
|
||||
''';
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
31
lib/screens/channel/video/video_player_interface.dart
Normal file
31
lib/screens/channel/video/video_player_interface.dart
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import 'package:frosty/models/channel.dart';
|
||||
import 'package:frosty/models/stream.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
|
||||
/// Abstraction over the video player store so both [VideoStore] (WebView)
|
||||
/// and [NativeVideoStore] can drive the same overlay and channel UI.
|
||||
abstract class VideoPlayerInterface {
|
||||
SettingsStore get settingsStore;
|
||||
|
||||
bool get loading;
|
||||
bool get paused;
|
||||
bool get overlayVisible;
|
||||
bool get isInPipMode;
|
||||
StreamTwitch? get streamInfo;
|
||||
Channel? get offlineChannelInfo;
|
||||
List<String> get availableStreamQualities;
|
||||
String get streamQuality;
|
||||
String? get latency;
|
||||
|
||||
void handleVideoTap();
|
||||
void handlePausePlay();
|
||||
void handleToggleOverlay();
|
||||
Future<void> handleRefresh();
|
||||
void requestPictureInPicture();
|
||||
void togglePictureInPicture();
|
||||
Future<void> updateStreamQualities();
|
||||
Future<void> setStreamQuality(String quality);
|
||||
Future<void> updateStreamInfo({bool forceUpdate});
|
||||
void handleAppResume();
|
||||
void dispose();
|
||||
}
|
||||
|
|
@ -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<void> updateStreamQualities() async {
|
||||
try {
|
||||
|
|
@ -382,6 +391,7 @@ abstract class VideoStoreBase with Store {
|
|||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@action
|
||||
Future<void> 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<void> 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<void> 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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
284
lib/screens/channel/vod/stores/vod_chat_store.dart
Normal file
284
lib/screens/channel/vod/stores/vod_chat_store.dart
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||
import 'package:frosty/models/gql_chat_comment.dart';
|
||||
import 'package:frosty/models/irc.dart';
|
||||
import 'package:frosty/screens/channel/chat/chat_render_context.dart';
|
||||
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
import 'package:mobx/mobx.dart';
|
||||
|
||||
part 'vod_chat_store.g.dart';
|
||||
|
||||
class VodChatStore = VodChatStoreBase with _$VodChatStore;
|
||||
|
||||
abstract class VodChatStoreBase with Store implements ChatRenderContext {
|
||||
/// The total maximum amount of messages in chat.
|
||||
static const _messageLimit = 5000;
|
||||
|
||||
/// The maximum amount of messages to render when autoscroll is enabled.
|
||||
static const _renderMessageLimit = 100;
|
||||
|
||||
@override
|
||||
final ChatAssetsStore assetsStore;
|
||||
|
||||
@override
|
||||
final AuthStore auth;
|
||||
|
||||
@override
|
||||
final SettingsStore settings;
|
||||
|
||||
@override
|
||||
final String channelId;
|
||||
|
||||
@override
|
||||
final revealedMessageIds = ObservableSet<String>();
|
||||
|
||||
final TwitchGqlApi twitchGqlApi;
|
||||
final String videoId;
|
||||
|
||||
/// The scroll controller that controls auto-scroll and resume-scroll behavior.
|
||||
final scrollController = ScrollController();
|
||||
|
||||
/// The amount of messages to free (remove) when the [_messageLimit] is reached.
|
||||
final _messagesToRemove = (_messageLimit * 0.2).toInt();
|
||||
|
||||
/// The periodic timer used for batching chat message re-renders.
|
||||
Timer? _messageBufferTimer;
|
||||
|
||||
/// The list of chat messages to add once autoscroll is resumed.
|
||||
final messageBuffer = ObservableList<IRCMessage>();
|
||||
|
||||
/// Timer used for dismissing the notification.
|
||||
Timer? _notificationTimer;
|
||||
|
||||
/// Fetched GQL comments waiting to be advanced based on playback position.
|
||||
final _commentBuffer = Queue<GqlChatComment>();
|
||||
|
||||
/// Cursor for fetching the next page of comments.
|
||||
String? _nextCursor;
|
||||
|
||||
/// Whether we're currently fetching comments from the API.
|
||||
bool _isFetching = false;
|
||||
|
||||
/// The last rendered content offset to avoid duplicates.
|
||||
int _lastRenderedOffset = -1;
|
||||
|
||||
/// A notification message to display above the chat.
|
||||
@readonly
|
||||
String? _notification;
|
||||
|
||||
/// The list of chat messages to render and display.
|
||||
@readonly
|
||||
var _messages = ObservableList<IRCMessage>();
|
||||
|
||||
/// If the chat should automatically scroll/jump to the latest message.
|
||||
@readonly
|
||||
var _autoScroll = true;
|
||||
|
||||
/// Current playback position in seconds.
|
||||
@observable
|
||||
int playbackPosition = 0;
|
||||
|
||||
/// Whether the store has been initialized.
|
||||
@readonly
|
||||
var _isInitialized = false;
|
||||
|
||||
/// The list of chat messages that should be rendered. Used to prevent jank when resuming scroll.
|
||||
@computed
|
||||
List<IRCMessage> get renderMessages {
|
||||
// If autoscroll is disabled, render ALL messages in chat.
|
||||
// The second condition is to prevent an out of index error with sublist.
|
||||
if (!_autoScroll || _messages.length < _renderMessageLimit) {
|
||||
return _messages;
|
||||
}
|
||||
|
||||
// When autoscroll is enabled, only show the first [_renderMessageLimit] messages.
|
||||
return _messages.sublist(_messages.length - _renderMessageLimit);
|
||||
}
|
||||
|
||||
VodChatStoreBase({
|
||||
required this.assetsStore,
|
||||
required this.auth,
|
||||
required this.settings,
|
||||
required this.channelId,
|
||||
required this.twitchGqlApi,
|
||||
required this.videoId,
|
||||
});
|
||||
|
||||
Future<void> init() async {
|
||||
await Future.wait([
|
||||
assetsStore.init().then((_) => _loadAssets()),
|
||||
_fetchComments(contentOffsetSeconds: 0),
|
||||
]);
|
||||
_startMessageBufferTimer();
|
||||
_setupScrollListener();
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
Future<void> _loadAssets() async {
|
||||
await assetsStore.assetsFuture(
|
||||
channelId: channelId,
|
||||
headers: auth.headersTwitch,
|
||||
onEmoteError: (error) {
|
||||
debugPrint(error.toString());
|
||||
return <dynamic>[];
|
||||
},
|
||||
onBadgeError: (error) {
|
||||
debugPrint(error.toString());
|
||||
return <dynamic>[];
|
||||
},
|
||||
showTwitchEmotes: settings.showTwitchEmotes,
|
||||
showTwitchBadges: settings.showTwitchBadges,
|
||||
show7TVEmotes: settings.show7TVEmotes,
|
||||
showBTTVEmotes: settings.showBTTVEmotes,
|
||||
showBTTVBadges: settings.showBTTVBadges,
|
||||
showFFZEmotes: settings.showFFZEmotes,
|
||||
showFFZBadges: settings.showFFZBadges,
|
||||
);
|
||||
}
|
||||
|
||||
void _startMessageBufferTimer() {
|
||||
_messageBufferTimer = Timer.periodic(
|
||||
const Duration(milliseconds: 200),
|
||||
(_) => addMessages(),
|
||||
);
|
||||
}
|
||||
|
||||
void _setupScrollListener() {
|
||||
scrollController.addListener(() {
|
||||
if (!scrollController.hasClients) return;
|
||||
final isAtBottom = scrollController.offset <= 0;
|
||||
if (isAtBottom != _autoScroll) {
|
||||
_autoScroll = isAtBottom;
|
||||
if (_autoScroll) addMessages();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
void addMessages() {
|
||||
if (!_autoScroll || messageBuffer.isEmpty) return;
|
||||
|
||||
_messages.addAll(messageBuffer);
|
||||
messageBuffer.clear();
|
||||
|
||||
if (_messages.length > _messageLimit) {
|
||||
_messages.removeRange(0, _messagesToRemove);
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-enables [_autoScroll] and jumps to the latest message.
|
||||
@action
|
||||
void resumeScroll() {
|
||||
_autoScroll = true;
|
||||
|
||||
scrollController.jumpTo(0);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
scrollController.jumpTo(0);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@action
|
||||
void revealMessage(String id) {
|
||||
revealedMessageIds.add(id);
|
||||
}
|
||||
|
||||
@override
|
||||
@action
|
||||
void updateNotification(String message) {
|
||||
_notificationTimer?.cancel();
|
||||
_notification = message;
|
||||
_notificationTimer = Timer(const Duration(seconds: 5), () {
|
||||
_notification = null;
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
void clearNotification() {
|
||||
_notificationTimer?.cancel();
|
||||
_notification = null;
|
||||
}
|
||||
|
||||
/// Called from the JS channel with the current video position.
|
||||
@action
|
||||
void updatePlaybackPosition(int offsetSeconds) {
|
||||
final previousPosition = playbackPosition;
|
||||
playbackPosition = offsetSeconds;
|
||||
|
||||
// Seek detection: if position jumped significantly, re-fetch.
|
||||
final delta = offsetSeconds - previousPosition;
|
||||
if (delta < -5 || delta > 10) {
|
||||
handleSeek(offsetSeconds);
|
||||
return;
|
||||
}
|
||||
|
||||
// Advance comments from buffer to messageBuffer.
|
||||
_advanceComments(offsetSeconds);
|
||||
|
||||
// Pre-fetch when running low.
|
||||
if (_commentBuffer.length < 50 && !_isFetching && _nextCursor != null) {
|
||||
_fetchComments(cursor: _nextCursor);
|
||||
}
|
||||
}
|
||||
|
||||
void _advanceComments(int offsetSeconds) {
|
||||
while (_commentBuffer.isNotEmpty &&
|
||||
_commentBuffer.first.contentOffsetSeconds <= offsetSeconds) {
|
||||
final comment = _commentBuffer.removeFirst();
|
||||
if (comment.contentOffsetSeconds > _lastRenderedOffset ||
|
||||
_lastRenderedOffset == -1) {
|
||||
_lastRenderedOffset = comment.contentOffsetSeconds;
|
||||
messageBuffer.add(IRCMessage.fromGqlComment(comment));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles an explicit seek to [offsetSeconds], clearing state and re-fetching.
|
||||
@action
|
||||
Future<void> handleSeek(int offsetSeconds) async {
|
||||
playbackPosition = offsetSeconds;
|
||||
_commentBuffer.clear();
|
||||
messageBuffer.clear();
|
||||
_messages.clear();
|
||||
_lastRenderedOffset = -1;
|
||||
_nextCursor = null;
|
||||
await _fetchComments(contentOffsetSeconds: offsetSeconds);
|
||||
}
|
||||
|
||||
Future<void> _fetchComments({
|
||||
int? contentOffsetSeconds,
|
||||
String? cursor,
|
||||
}) async {
|
||||
if (_isFetching) return;
|
||||
_isFetching = true;
|
||||
try {
|
||||
final response = await twitchGqlApi.getVideoComments(
|
||||
videoId: videoId,
|
||||
contentOffsetSeconds: contentOffsetSeconds,
|
||||
cursor: cursor,
|
||||
);
|
||||
_commentBuffer.addAll(response.comments);
|
||||
if (response.hasNextPage) {
|
||||
_nextCursor = response.cursor;
|
||||
} else {
|
||||
_nextCursor = null;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to fetch VOD comments: $e');
|
||||
} finally {
|
||||
_isFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_messageBufferTimer?.cancel();
|
||||
_notificationTimer?.cancel();
|
||||
scrollController.dispose();
|
||||
}
|
||||
}
|
||||
203
lib/screens/channel/vod/stores/vod_chat_store.g.dart
Normal file
203
lib/screens/channel/vod/stores/vod_chat_store.g.dart
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'vod_chat_store.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// StoreGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
|
||||
|
||||
mixin _$VodChatStore on VodChatStoreBase, Store {
|
||||
Computed<List<IRCMessage>>? _$renderMessagesComputed;
|
||||
|
||||
@override
|
||||
List<IRCMessage> get renderMessages =>
|
||||
(_$renderMessagesComputed ??= Computed<List<IRCMessage>>(
|
||||
() => super.renderMessages,
|
||||
name: 'VodChatStoreBase.renderMessages',
|
||||
)).value;
|
||||
|
||||
late final _$_notificationAtom = Atom(
|
||||
name: 'VodChatStoreBase._notification',
|
||||
context: context,
|
||||
);
|
||||
|
||||
String? get notification {
|
||||
_$_notificationAtom.reportRead();
|
||||
return super._notification;
|
||||
}
|
||||
|
||||
@override
|
||||
String? get _notification => notification;
|
||||
|
||||
@override
|
||||
set _notification(String? value) {
|
||||
_$_notificationAtom.reportWrite(value, super._notification, () {
|
||||
super._notification = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_messagesAtom = Atom(
|
||||
name: 'VodChatStoreBase._messages',
|
||||
context: context,
|
||||
);
|
||||
|
||||
ObservableList<IRCMessage> get messages {
|
||||
_$_messagesAtom.reportRead();
|
||||
return super._messages;
|
||||
}
|
||||
|
||||
@override
|
||||
ObservableList<IRCMessage> get _messages => messages;
|
||||
|
||||
@override
|
||||
set _messages(ObservableList<IRCMessage> value) {
|
||||
_$_messagesAtom.reportWrite(value, super._messages, () {
|
||||
super._messages = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_autoScrollAtom = Atom(
|
||||
name: 'VodChatStoreBase._autoScroll',
|
||||
context: context,
|
||||
);
|
||||
|
||||
bool get autoScroll {
|
||||
_$_autoScrollAtom.reportRead();
|
||||
return super._autoScroll;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get _autoScroll => autoScroll;
|
||||
|
||||
@override
|
||||
set _autoScroll(bool value) {
|
||||
_$_autoScrollAtom.reportWrite(value, super._autoScroll, () {
|
||||
super._autoScroll = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$playbackPositionAtom = Atom(
|
||||
name: 'VodChatStoreBase.playbackPosition',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
int get playbackPosition {
|
||||
_$playbackPositionAtom.reportRead();
|
||||
return super.playbackPosition;
|
||||
}
|
||||
|
||||
@override
|
||||
set playbackPosition(int value) {
|
||||
_$playbackPositionAtom.reportWrite(value, super.playbackPosition, () {
|
||||
super.playbackPosition = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_isInitializedAtom = Atom(
|
||||
name: 'VodChatStoreBase._isInitialized',
|
||||
context: context,
|
||||
);
|
||||
|
||||
bool get isInitialized {
|
||||
_$_isInitializedAtom.reportRead();
|
||||
return super._isInitialized;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get _isInitialized => isInitialized;
|
||||
|
||||
@override
|
||||
set _isInitialized(bool value) {
|
||||
_$_isInitializedAtom.reportWrite(value, super._isInitialized, () {
|
||||
super._isInitialized = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$VodChatStoreBaseActionController = ActionController(
|
||||
name: 'VodChatStoreBase',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
void addMessages() {
|
||||
final _$actionInfo = _$VodChatStoreBaseActionController.startAction(
|
||||
name: 'VodChatStoreBase.addMessages',
|
||||
);
|
||||
try {
|
||||
return super.addMessages();
|
||||
} finally {
|
||||
_$VodChatStoreBaseActionController.endAction(_$actionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void resumeScroll() {
|
||||
final _$actionInfo = _$VodChatStoreBaseActionController.startAction(
|
||||
name: 'VodChatStoreBase.resumeScroll',
|
||||
);
|
||||
try {
|
||||
return super.resumeScroll();
|
||||
} finally {
|
||||
_$VodChatStoreBaseActionController.endAction(_$actionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void revealMessage(String id) {
|
||||
final _$actionInfo = _$VodChatStoreBaseActionController.startAction(
|
||||
name: 'VodChatStoreBase.revealMessage',
|
||||
);
|
||||
try {
|
||||
return super.revealMessage(id);
|
||||
} finally {
|
||||
_$VodChatStoreBaseActionController.endAction(_$actionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void updateNotification(String message) {
|
||||
final _$actionInfo = _$VodChatStoreBaseActionController.startAction(
|
||||
name: 'VodChatStoreBase.updateNotification',
|
||||
);
|
||||
try {
|
||||
return super.updateNotification(message);
|
||||
} finally {
|
||||
_$VodChatStoreBaseActionController.endAction(_$actionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void clearNotification() {
|
||||
final _$actionInfo = _$VodChatStoreBaseActionController.startAction(
|
||||
name: 'VodChatStoreBase.clearNotification',
|
||||
);
|
||||
try {
|
||||
return super.clearNotification();
|
||||
} finally {
|
||||
_$VodChatStoreBaseActionController.endAction(_$actionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void updatePlaybackPosition(int offsetSeconds) {
|
||||
final _$actionInfo = _$VodChatStoreBaseActionController.startAction(
|
||||
name: 'VodChatStoreBase.updatePlaybackPosition',
|
||||
);
|
||||
try {
|
||||
return super.updatePlaybackPosition(offsetSeconds);
|
||||
} finally {
|
||||
_$VodChatStoreBaseActionController.endAction(_$actionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''
|
||||
playbackPosition: ${playbackPosition},
|
||||
renderMessages: ${renderMessages}
|
||||
''';
|
||||
}
|
||||
}
|
||||
73
lib/screens/channel/vod/stores/vod_list_store.dart
Normal file
73
lib/screens/channel/vod/stores/vod_list_store.dart
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:frosty/apis/twitch_api.dart';
|
||||
import 'package:frosty/models/video.dart';
|
||||
import 'package:mobx/mobx.dart';
|
||||
|
||||
part 'vod_list_store.g.dart';
|
||||
|
||||
class VodListStore = VodListStoreBase with _$VodListStore;
|
||||
|
||||
abstract class VodListStoreBase with Store {
|
||||
final TwitchApi twitchApi;
|
||||
final String userId;
|
||||
|
||||
@readonly
|
||||
var _videos = ObservableList<VideoTwitch>();
|
||||
|
||||
@readonly
|
||||
var _isLoading = false;
|
||||
|
||||
@readonly
|
||||
var _error = '';
|
||||
|
||||
String? _cursor;
|
||||
bool _hasMore = true;
|
||||
|
||||
VodListStoreBase({
|
||||
required this.twitchApi,
|
||||
required this.userId,
|
||||
});
|
||||
|
||||
@action
|
||||
Future<void> fetchVideos() async {
|
||||
_isLoading = true;
|
||||
_error = '';
|
||||
try {
|
||||
final response = await twitchApi.getVideos(userId: userId);
|
||||
_videos = response.data.asObservable();
|
||||
_cursor = response.pagination['cursor'];
|
||||
_hasMore = response.data.length >= 20;
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
debugPrint('Failed to fetch VODs: $e');
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
Future<void> fetchMore() async {
|
||||
if (_isLoading || !_hasMore || _cursor == null) return;
|
||||
_isLoading = true;
|
||||
try {
|
||||
final response = await twitchApi.getVideos(
|
||||
userId: userId,
|
||||
cursor: _cursor,
|
||||
);
|
||||
_videos.addAll(response.data);
|
||||
_cursor = response.pagination['cursor'];
|
||||
_hasMore = response.data.length >= 20;
|
||||
} catch (e) {
|
||||
debugPrint('Failed to fetch more VODs: $e');
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
Future<void> refresh() async {
|
||||
_cursor = null;
|
||||
_hasMore = true;
|
||||
await fetchVideos();
|
||||
}
|
||||
}
|
||||
108
lib/screens/channel/vod/stores/vod_list_store.g.dart
Normal file
108
lib/screens/channel/vod/stores/vod_list_store.g.dart
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'vod_list_store.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// StoreGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
|
||||
|
||||
mixin _$VodListStore on VodListStoreBase, Store {
|
||||
late final _$_videosAtom = Atom(
|
||||
name: 'VodListStoreBase._videos',
|
||||
context: context,
|
||||
);
|
||||
|
||||
ObservableList<VideoTwitch> get videos {
|
||||
_$_videosAtom.reportRead();
|
||||
return super._videos;
|
||||
}
|
||||
|
||||
@override
|
||||
ObservableList<VideoTwitch> get _videos => videos;
|
||||
|
||||
@override
|
||||
set _videos(ObservableList<VideoTwitch> value) {
|
||||
_$_videosAtom.reportWrite(value, super._videos, () {
|
||||
super._videos = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_isLoadingAtom = Atom(
|
||||
name: 'VodListStoreBase._isLoading',
|
||||
context: context,
|
||||
);
|
||||
|
||||
bool get isLoading {
|
||||
_$_isLoadingAtom.reportRead();
|
||||
return super._isLoading;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get _isLoading => isLoading;
|
||||
|
||||
@override
|
||||
set _isLoading(bool value) {
|
||||
_$_isLoadingAtom.reportWrite(value, super._isLoading, () {
|
||||
super._isLoading = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_errorAtom = Atom(
|
||||
name: 'VodListStoreBase._error',
|
||||
context: context,
|
||||
);
|
||||
|
||||
String get error {
|
||||
_$_errorAtom.reportRead();
|
||||
return super._error;
|
||||
}
|
||||
|
||||
@override
|
||||
String get _error => error;
|
||||
|
||||
@override
|
||||
set _error(String value) {
|
||||
_$_errorAtom.reportWrite(value, super._error, () {
|
||||
super._error = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$fetchVideosAsyncAction = AsyncAction(
|
||||
'VodListStoreBase.fetchVideos',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> fetchVideos() {
|
||||
return _$fetchVideosAsyncAction.run(() => super.fetchVideos());
|
||||
}
|
||||
|
||||
late final _$fetchMoreAsyncAction = AsyncAction(
|
||||
'VodListStoreBase.fetchMore',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> fetchMore() {
|
||||
return _$fetchMoreAsyncAction.run(() => super.fetchMore());
|
||||
}
|
||||
|
||||
late final _$refreshAsyncAction = AsyncAction(
|
||||
'VodListStoreBase.refresh',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> refresh() {
|
||||
return _$refreshAsyncAction.run(() => super.refresh());
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''
|
||||
|
||||
''';
|
||||
}
|
||||
}
|
||||
184
lib/screens/channel/vod/vod_card.dart
Normal file
184
lib/screens/channel/vod/vod_card.dart
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:frosty/constants.dart';
|
||||
import 'package:frosty/models/video.dart';
|
||||
import 'package:frosty/screens/channel/vod/vod_player.dart';
|
||||
import 'package:frosty/utils/context_extensions.dart';
|
||||
import 'package:frosty/widgets/frosty_cached_network_image.dart';
|
||||
import 'package:frosty/widgets/skeleton_loader.dart';
|
||||
|
||||
/// A tappable card widget that displays a VOD's thumbnail and details.
|
||||
class VodCard extends StatelessWidget {
|
||||
final VideoTwitch video;
|
||||
|
||||
const VodCard({super.key, required this.video});
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes.remainder(60);
|
||||
final seconds = duration.inSeconds.remainder(60);
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours}h${minutes.toString().padLeft(2, '0')}m${seconds.toString().padLeft(2, '0')}s';
|
||||
}
|
||||
return '${minutes}m${seconds.toString().padLeft(2, '0')}s';
|
||||
}
|
||||
|
||||
String _formatRelativeDate(String createdAt) {
|
||||
final date = DateTime.tryParse(createdAt);
|
||||
if (date == null) return createdAt;
|
||||
|
||||
final now = DateTime.now().toUtc();
|
||||
final difference = now.difference(date);
|
||||
|
||||
if (difference.inDays >= 365) {
|
||||
final years = difference.inDays ~/ 365;
|
||||
return '$years ${years == 1 ? 'year' : 'years'} ago';
|
||||
} else if (difference.inDays >= 30) {
|
||||
final months = difference.inDays ~/ 30;
|
||||
return '$months ${months == 1 ? 'month' : 'months'} ago';
|
||||
} else if (difference.inDays >= 1) {
|
||||
return '${difference.inDays} ${difference.inDays == 1 ? 'day' : 'days'} ago';
|
||||
} else if (difference.inHours >= 1) {
|
||||
return '${difference.inHours} ${difference.inHours == 1 ? 'hour' : 'hours'} ago';
|
||||
} else {
|
||||
return '${difference.inMinutes} ${difference.inMinutes == 1 ? 'minute' : 'minutes'} ago';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||
final thumbnailWidth = min((size.width * pixelRatio) ~/ 3, 1920);
|
||||
final thumbnailHeight = min((thumbnailWidth * (9 / 16)).toInt(), 1080);
|
||||
|
||||
final duration = parseTwitchDuration(video.duration);
|
||||
|
||||
final thumbnailUrl = video.thumbnailUrl
|
||||
.replaceFirst('%{width}', '$thumbnailWidth')
|
||||
.replaceFirst('%{height}', '$thumbnailHeight');
|
||||
|
||||
final thumbnail = AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: thumbnailUrl.isEmpty
|
||||
? Container(color: Colors.black12)
|
||||
: FrostyCachedNetworkImage(
|
||||
imageUrl: thumbnailUrl,
|
||||
placeholder: (context, url) => const SkeletonLoader(
|
||||
borderRadius: kCardBorderRadius,
|
||||
),
|
||||
useOldImageOnUrlChange: true,
|
||||
),
|
||||
);
|
||||
|
||||
final fontColor = DefaultTextStyle.of(context).style.color;
|
||||
const subFontSize = 14.0;
|
||||
|
||||
final imageSection = ClipRRect(
|
||||
borderRadius: kCardBorderRadius,
|
||||
child: Stack(
|
||||
alignment: AlignmentDirectional.bottomEnd,
|
||||
children: [
|
||||
thumbnail,
|
||||
Container(
|
||||
margin: const EdgeInsets.all(3),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_formatDuration(duration),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: context.frostyColors.overlayOnSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final vodInfoSection = Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 2,
|
||||
children: [
|
||||
Tooltip(
|
||||
message: video.title.trim(),
|
||||
preferBelow: false,
|
||||
child: Text(
|
||||
video.title.trim(),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 2,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: fontColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatRelativeDate(video.createdAt),
|
||||
style: TextStyle(
|
||||
fontSize: subFontSize,
|
||||
color: fontColor?.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${_formatViewCount(video.viewCount)} views',
|
||||
style: TextStyle(
|
||||
fontSize: subFontSize,
|
||||
color: fontColor?.withValues(alpha: 0.8),
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return InkWell(
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => VodPlayer(
|
||||
videoId: video.id,
|
||||
channelId: video.userId,
|
||||
title: video.title,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
left: 16 + MediaQuery.of(context).padding.left,
|
||||
right: 16 + MediaQuery.of(context).padding.right,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(child: imageSection),
|
||||
Flexible(flex: 2, child: vodInfoSection),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatViewCount(int count) {
|
||||
if (count >= 1000000) {
|
||||
return '${(count / 1000000).toStringAsFixed(1)}M';
|
||||
} else if (count >= 1000) {
|
||||
return '${(count / 1000).toStringAsFixed(1)}K';
|
||||
}
|
||||
return count.toString();
|
||||
}
|
||||
}
|
||||
82
lib/screens/channel/vod/vod_list.dart
Normal file
82
lib/screens/channel/vod/vod_list.dart
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:frosty/screens/channel/vod/stores/vod_list_store.dart';
|
||||
import 'package:frosty/screens/channel/vod/vod_card.dart';
|
||||
import 'package:frosty/utils/context_extensions.dart';
|
||||
import 'package:frosty/widgets/alert_message.dart';
|
||||
|
||||
class VodList extends StatefulWidget {
|
||||
final String userId;
|
||||
final String displayName;
|
||||
|
||||
const VodList({
|
||||
super.key,
|
||||
required this.userId,
|
||||
required this.displayName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VodList> createState() => _VodListState();
|
||||
}
|
||||
|
||||
class _VodListState extends State<VodList> {
|
||||
late final VodListStore _store = VodListStore(
|
||||
twitchApi: context.twitchApi,
|
||||
userId: widget.userId,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_store.fetchVideos();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('${widget.displayName} — Past broadcasts')),
|
||||
body: Observer(
|
||||
builder: (context) {
|
||||
if (_store.error.isNotEmpty && _store.videos.isEmpty) {
|
||||
return Center(
|
||||
child: AlertMessage(message: _store.error),
|
||||
);
|
||||
}
|
||||
|
||||
if (_store.videos.isEmpty && !_store.isLoading) {
|
||||
return const Center(
|
||||
child: AlertMessage(message: 'No past broadcasts found'),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator.adaptive(
|
||||
onRefresh: _store.refresh,
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
if (notification is ScrollEndNotification &&
|
||||
notification.metrics.extentAfter < 200) {
|
||||
_store.fetchMore();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: ListView.builder(
|
||||
itemCount: _store.videos.length + (_store.isLoading ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == _store.videos.length) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
),
|
||||
);
|
||||
}
|
||||
return VodCard(video: _store.videos[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
282
lib/screens/channel/vod/vod_player.dart
Normal file
282
lib/screens/channel/vod/vod_player.dart
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
|
||||
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
|
||||
import 'package:frosty/screens/channel/vod/stores/vod_chat_store.dart';
|
||||
import 'package:frosty/utils/context_extensions.dart';
|
||||
import 'package:frosty/widgets/frosty_notification.dart';
|
||||
import 'package:frosty/widgets/frosty_scrollbar.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:webview_flutter_android/webview_flutter_android.dart';
|
||||
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
|
||||
|
||||
/// VOD player + chat replay screen.
|
||||
///
|
||||
/// Layout matches the live channel view [VideoChat]: no AppBar,
|
||||
/// video on top (16:9), chat below (expanded).
|
||||
class VodPlayer extends StatefulWidget {
|
||||
final String videoId;
|
||||
final String channelId;
|
||||
final String title;
|
||||
|
||||
const VodPlayer({
|
||||
super.key,
|
||||
required this.videoId,
|
||||
required this.channelId,
|
||||
required this.title,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VodPlayer> createState() => _VodPlayerState();
|
||||
}
|
||||
|
||||
class _VodPlayerState extends State<VodPlayer> {
|
||||
late final VodChatStore _vodChatStore;
|
||||
late final WebViewController _webViewController;
|
||||
|
||||
String get _videoUrl =>
|
||||
'https://player.twitch.tv/?video=v${widget.videoId}&parent=frosty&muted=false';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_vodChatStore = VodChatStore(
|
||||
assetsStore: ChatAssetsStore(
|
||||
twitchApi: context.twitchApi,
|
||||
bttvApi: context.bttvApi,
|
||||
ffzApi: context.ffzApi,
|
||||
sevenTVApi: context.sevenTVApi,
|
||||
globalAssetsStore: context.globalAssetsStore,
|
||||
),
|
||||
auth: context.authStore,
|
||||
settings: context.settingsStore,
|
||||
channelId: widget.channelId,
|
||||
twitchGqlApi: context.twitchGqlApi,
|
||||
videoId: widget.videoId,
|
||||
);
|
||||
|
||||
_vodChatStore.init();
|
||||
_initWebView();
|
||||
}
|
||||
|
||||
void _initWebView() {
|
||||
// Platform-specific params for autoplay.
|
||||
late final PlatformWebViewControllerCreationParams params;
|
||||
if (WebViewPlatform.instance is WebKitWebViewPlatform) {
|
||||
params = WebKitWebViewControllerCreationParams(
|
||||
allowsInlineMediaPlayback: true,
|
||||
mediaTypesRequiringUserAction: const <PlaybackMediaTypes>{},
|
||||
);
|
||||
} else {
|
||||
params = const PlatformWebViewControllerCreationParams();
|
||||
}
|
||||
|
||||
_webViewController =
|
||||
WebViewController.fromPlatformCreationParams(params)
|
||||
..setBackgroundColor(Colors.black)
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..addJavaScriptChannel(
|
||||
'VodPosition',
|
||||
onMessageReceived: (message) {
|
||||
final text = message.message;
|
||||
if (text.startsWith('seek:')) {
|
||||
final seconds =
|
||||
double.tryParse(text.substring(5))?.toInt() ?? 0;
|
||||
_vodChatStore.handleSeek(seconds);
|
||||
} else {
|
||||
final seconds = double.tryParse(text)?.toInt() ?? 0;
|
||||
_vodChatStore.updatePlaybackPosition(seconds);
|
||||
}
|
||||
},
|
||||
)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onPageFinished: (url) async {
|
||||
if (!url.contains('player.twitch.tv')) return;
|
||||
await _injectPositionTracker();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Android: disable user gesture requirement for autoplay.
|
||||
if (_webViewController.platform is AndroidWebViewController) {
|
||||
(_webViewController.platform as AndroidWebViewController)
|
||||
.setMediaPlaybackRequiresUserGesture(false);
|
||||
}
|
||||
|
||||
_webViewController.loadRequest(Uri.parse(_videoUrl));
|
||||
}
|
||||
|
||||
Future<void> _injectPositionTracker() async {
|
||||
// Wait for the video element to be available, then poll currentTime
|
||||
// and listen for seek events.
|
||||
await _webViewController.runJavaScript('''
|
||||
(function() {
|
||||
function tryAttach() {
|
||||
var video = document.querySelector('video');
|
||||
if (!video) {
|
||||
setTimeout(tryAttach, 500);
|
||||
return;
|
||||
}
|
||||
setInterval(function() {
|
||||
VodPosition.postMessage(Math.floor(video.currentTime).toString());
|
||||
}, 1000);
|
||||
video.addEventListener('seeked', function() {
|
||||
VodPosition.postMessage('seek:' + Math.floor(video.currentTime).toString());
|
||||
});
|
||||
}
|
||||
tryAttach();
|
||||
})();
|
||||
''');
|
||||
}
|
||||
|
||||
Widget _buildWebView() {
|
||||
if (Platform.isAndroid &&
|
||||
WebViewPlatform.instance is AndroidWebViewPlatform) {
|
||||
return WebViewWidget.fromPlatformCreationParams(
|
||||
params: AndroidWebViewWidgetCreationParams(
|
||||
controller: _webViewController.platform,
|
||||
displayWithHybridComposition:
|
||||
!context.settingsStore.useTextureRendering,
|
||||
),
|
||||
);
|
||||
}
|
||||
return WebViewWidget(controller: _webViewController);
|
||||
}
|
||||
|
||||
Widget _buildChatList() {
|
||||
return Observer(
|
||||
builder: (context) {
|
||||
return MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
textScaler: _vodChatStore.settings.messageScale.textScaler,
|
||||
),
|
||||
child: DefaultTextStyle(
|
||||
style: context.defaultTextStyle.copyWith(
|
||||
fontSize: _vodChatStore.settings.fontSize,
|
||||
),
|
||||
child: Stack(
|
||||
alignment: AlignmentDirectional.bottomCenter,
|
||||
children: [
|
||||
FrostyScrollbar(
|
||||
controller: _vodChatStore.scrollController,
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: Observer(
|
||||
builder: (context) {
|
||||
final messages = _vodChatStore.renderMessages;
|
||||
return ListView.builder(
|
||||
reverse: true,
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
addAutomaticKeepAlives: false,
|
||||
controller: _vodChatStore.scrollController,
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) => ChatMessage(
|
||||
ircMessage:
|
||||
messages[messages.length - 1 - index],
|
||||
chatStore: _vodChatStore,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
_buildResumeScrollButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResumeScrollButton() {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
return AnimatedPadding(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: EdgeInsets.only(
|
||||
left: 4,
|
||||
top: 4,
|
||||
right: 4,
|
||||
bottom: MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: Observer(
|
||||
builder: (_) {
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
switchInCurve: Curves.easeOut,
|
||||
switchOutCurve: Curves.easeIn,
|
||||
child: _vodChatStore.autoScroll
|
||||
? null
|
||||
: ElevatedButton.icon(
|
||||
onPressed: _vodChatStore.resumeScroll,
|
||||
icon: const Icon(Icons.arrow_downward_rounded),
|
||||
label: Text(
|
||||
_vodChatStore.messageBuffer.isNotEmpty
|
||||
? '${_vodChatStore.messageBuffer.length} new ${_vodChatStore.messageBuffer.length == 1 ? 'message' : 'messages'}'
|
||||
: 'Resume scroll',
|
||||
style: const TextStyle(
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: _buildWebView(),
|
||||
),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
_buildChatList(),
|
||||
Observer(
|
||||
builder: (_) => AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
switchInCurve: Curves.easeOut,
|
||||
switchOutCurve: Curves.easeIn,
|
||||
child: _vodChatStore.notification != null
|
||||
? Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: FrostyNotification(
|
||||
message: _vodChatStore.notification!,
|
||||
onDismissed: _vodChatStore.clearNotification,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_vodChatStore.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -166,9 +166,8 @@ class _HomeState extends State<Home> {
|
|||
NavigationDestination(
|
||||
icon: Icon(
|
||||
Icons.favorite_border_rounded,
|
||||
color: _homeStore.selectedIndex == 0
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
color: theme.colorScheme.onSurfaceVariant
|
||||
.withValues(alpha: 0.5),
|
||||
),
|
||||
selectedIcon: Icon(
|
||||
Icons.favorite_rounded,
|
||||
|
|
@ -180,11 +179,8 @@ class _HomeState extends State<Home> {
|
|||
NavigationDestination(
|
||||
icon: Icon(
|
||||
Icons.arrow_upward_rounded,
|
||||
color:
|
||||
_homeStore.selectedIndex ==
|
||||
(_authStore.isLoggedIn ? 1 : 0)
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
color: theme.colorScheme.onSurfaceVariant
|
||||
.withValues(alpha: 0.5),
|
||||
),
|
||||
selectedIcon: Icon(
|
||||
Icons.arrow_upward_rounded,
|
||||
|
|
@ -196,11 +192,8 @@ class _HomeState extends State<Home> {
|
|||
NavigationDestination(
|
||||
icon: Icon(
|
||||
Icons.search_rounded,
|
||||
color:
|
||||
_homeStore.selectedIndex ==
|
||||
(_authStore.isLoggedIn ? 2 : 1)
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
color: theme.colorScheme.onSurfaceVariant
|
||||
.withValues(alpha: 0.5),
|
||||
),
|
||||
selectedIcon: Icon(
|
||||
Icons.search_rounded,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<ChatSettings> createState() => _ChatSettingsState();
|
||||
}
|
||||
|
||||
class _ChatSettingsState extends State<ChatSettings> {
|
||||
var showPreview = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settingsStore = widget.settingsStore;
|
||||
|
||||
return Observer(
|
||||
builder: (context) => SettingsPageLayout(
|
||||
children: [
|
||||
const SectionHeader('Message sizing', isFirst: true),
|
||||
ExpansionTile(
|
||||
title: const Text('Preview'),
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: DefaultTextStyle(
|
||||
style: DefaultTextStyle.of(
|
||||
context,
|
||||
).style.copyWith(fontSize: settingsStore.fontSize),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: FrostyCachedNetworkImage(
|
||||
imageUrl:
|
||||
'https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/3',
|
||||
height:
|
||||
defaultBadgeSize * settingsStore.badgeScale,
|
||||
width:
|
||||
defaultBadgeSize * settingsStore.badgeScale,
|
||||
),
|
||||
),
|
||||
const TextSpan(text: ' Badge and emote preview. '),
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: FrostyCachedNetworkImage(
|
||||
imageUrl:
|
||||
'https://static-cdn.jtvnw.net/emoticons/v2/425618/default/dark/3.0',
|
||||
height:
|
||||
defaultEmoteSize * settingsStore.emoteScale,
|
||||
width:
|
||||
defaultEmoteSize * settingsStore.emoteScale,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
textScaler: settingsStore.messageScale.textScaler,
|
||||
),
|
||||
SizedBox(height: settingsStore.messageSpacing),
|
||||
Text(
|
||||
'Hello! Here\'s a text preview.',
|
||||
textScaler: settingsStore.messageScale.textScaler,
|
||||
),
|
||||
SizedBox(height: settingsStore.messageSpacing),
|
||||
Text(
|
||||
'And another for spacing without an emote!',
|
||||
textScaler: settingsStore.messageScale.textScaler,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsListSlider(
|
||||
title: 'Badge scale',
|
||||
trailing: '${settingsStore.badgeScale.toStringAsFixed(2)}x',
|
||||
value: settingsStore.badgeScale,
|
||||
min: 0.25,
|
||||
max: 3.0,
|
||||
divisions: 11,
|
||||
onChanged: (newValue) => settingsStore.badgeScale = newValue,
|
||||
),
|
||||
SettingsListSlider(
|
||||
title: 'Emote scale',
|
||||
trailing: '${settingsStore.emoteScale.toStringAsFixed(2)}x',
|
||||
value: settingsStore.emoteScale,
|
||||
min: 0.25,
|
||||
max: 3.0,
|
||||
divisions: 11,
|
||||
onChanged: (newValue) => settingsStore.emoteScale = newValue,
|
||||
),
|
||||
SettingsListSlider(
|
||||
title: 'Message scale',
|
||||
trailing: '${settingsStore.messageScale.toStringAsFixed(2)}x',
|
||||
value: settingsStore.messageScale,
|
||||
min: 0.5,
|
||||
max: 2.0,
|
||||
divisions: 6,
|
||||
onChanged: (newValue) => settingsStore.messageScale = newValue,
|
||||
),
|
||||
SettingsListSlider(
|
||||
title: 'Message spacing',
|
||||
trailing: '${settingsStore.messageSpacing.toStringAsFixed(0)}px',
|
||||
value: settingsStore.messageSpacing,
|
||||
max: 30.0,
|
||||
divisions: 15,
|
||||
onChanged: (newValue) => settingsStore.messageSpacing = newValue,
|
||||
),
|
||||
SettingsListSlider(
|
||||
title: 'Font size',
|
||||
trailing: settingsStore.fontSize.toInt().toString(),
|
||||
value: settingsStore.fontSize,
|
||||
min: 5,
|
||||
max: 20,
|
||||
divisions: 15,
|
||||
onChanged: (newValue) => settingsStore.fontSize = newValue,
|
||||
),
|
||||
const SectionHeader('Message appearance'),
|
||||
SettingsListSwitch(
|
||||
title: 'Show deleted messages',
|
||||
subtitle: const Text(
|
||||
'Restores the original message of deleted messages.',
|
||||
),
|
||||
value: settingsStore.showDeletedMessages,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.showDeletedMessages = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Show message dividers',
|
||||
value: settingsStore.showChatMessageDividers,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.showChatMessageDividers = newValue,
|
||||
),
|
||||
SettingsListSelect(
|
||||
title: 'Message timestamps',
|
||||
selectedOption: timestampNames[settingsStore.timestampType.index],
|
||||
options: timestampNames,
|
||||
onChanged: (newValue) => settingsStore.timestampType =
|
||||
TimestampType.values[timestampNames.indexOf(newValue)],
|
||||
),
|
||||
const SectionHeader('Delay and latency'),
|
||||
SettingsListSwitch(
|
||||
title: 'Sync message delay and stream latency',
|
||||
value: settingsStore.autoSyncChatDelay,
|
||||
onChanged: (newValue) => settingsStore.autoSyncChatDelay = newValue,
|
||||
),
|
||||
if (!settingsStore.autoSyncChatDelay)
|
||||
SettingsListSlider(
|
||||
title: 'Message delay',
|
||||
trailing: '${settingsStore.chatDelay.toInt()} seconds',
|
||||
subtitle:
|
||||
'Adds a delay before each message is rendered in chat. ${Platform.isIOS ? '15 seconds is recommended for iOS.' : ''}',
|
||||
value: settingsStore.chatDelay,
|
||||
max: 30.0,
|
||||
divisions: 30,
|
||||
onChanged: (newValue) => settingsStore.chatDelay = newValue,
|
||||
),
|
||||
const SectionHeader('Alerts'),
|
||||
SettingsListSwitch(
|
||||
title: 'Highlight first time chatters',
|
||||
value: settingsStore.highlightFirstTimeChatter,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.highlightFirstTimeChatter = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Show notices',
|
||||
subtitle: const Text(
|
||||
'Shows notices such as subs and re-subs, announcements, and raids.',
|
||||
),
|
||||
value: settingsStore.showUserNotices,
|
||||
onChanged: (newValue) => settingsStore.showUserNotices = newValue,
|
||||
),
|
||||
const SectionHeader('Layout'),
|
||||
SettingsListSwitch(
|
||||
title: 'Move emote menu button left',
|
||||
subtitle: const Text(
|
||||
'Places the emote menu button on the left side to avoid accidental presses.',
|
||||
),
|
||||
value: settingsStore.emoteMenuButtonOnLeft,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.emoteMenuButtonOnLeft = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Persist chat tabs',
|
||||
subtitle: const Text(
|
||||
'Secondary chat tabs are remembered when switching channels.',
|
||||
),
|
||||
value: settingsStore.persistChatTabs,
|
||||
onChanged: (newValue) {
|
||||
settingsStore.persistChatTabs = newValue;
|
||||
if (!newValue) {
|
||||
settingsStore.secondaryTabs = [];
|
||||
}
|
||||
},
|
||||
),
|
||||
const SectionHeader('Landscape mode'),
|
||||
SettingsListSwitch(
|
||||
title: 'Move chat left',
|
||||
value: settingsStore.landscapeChatLeftSide,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.landscapeChatLeftSide = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Force vertical chat',
|
||||
subtitle: const Text(
|
||||
'Intended for tablets and other larger displays.',
|
||||
),
|
||||
value: settingsStore.landscapeForceVerticalChat,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.landscapeForceVerticalChat = newValue,
|
||||
),
|
||||
SettingsListSelect(
|
||||
title: 'Fill notch side',
|
||||
subtitle:
|
||||
'Overrides and fills the available space in devices with a display notch.',
|
||||
selectedOption:
|
||||
landscapeCutoutNames[settingsStore.landscapeCutout.index],
|
||||
options: landscapeCutoutNames,
|
||||
onChanged: (newValue) => settingsStore.landscapeCutout =
|
||||
LandscapeCutoutType.values[landscapeCutoutNames.indexOf(
|
||||
newValue,
|
||||
)],
|
||||
),
|
||||
SettingsListSlider(
|
||||
title: 'Chat overlay opacity',
|
||||
trailing:
|
||||
'${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%',
|
||||
subtitle:
|
||||
'Sets the opacity (transparency) of the overlay chat in fullscreen mode.',
|
||||
value: settingsStore.fullScreenChatOverlayOpacity,
|
||||
divisions: 10,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.fullScreenChatOverlayOpacity = newValue,
|
||||
),
|
||||
const SectionHeader('Muted keywords'),
|
||||
SettingsMutedWords(settingsStore: settingsStore),
|
||||
SettingsListSwitch(
|
||||
title: 'Match whole words',
|
||||
subtitle: const Text(
|
||||
'Only matches whole words instead of partial matches.',
|
||||
),
|
||||
value: settingsStore.matchWholeWord,
|
||||
onChanged: (newValue) => settingsStore.matchWholeWord = newValue,
|
||||
),
|
||||
const SectionHeader('Autocomplete'),
|
||||
SettingsListSwitch(
|
||||
title: 'Show autocomplete bar',
|
||||
subtitle: const Text(
|
||||
'Shows a bar containing matching emotes and mentions while typing.',
|
||||
),
|
||||
value: settingsStore.autocomplete,
|
||||
onChanged: (newValue) => settingsStore.autocomplete = newValue,
|
||||
),
|
||||
const SectionHeader('Emotes and badges'),
|
||||
SettingsListSwitch(
|
||||
title: 'Show Twitch emotes',
|
||||
value: settingsStore.showTwitchEmotes,
|
||||
onChanged: (newValue) => settingsStore.showTwitchEmotes = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Show Twitch badges',
|
||||
value: settingsStore.showTwitchBadges,
|
||||
onChanged: (newValue) => settingsStore.showTwitchBadges = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Show 7TV emotes',
|
||||
value: settingsStore.show7TVEmotes,
|
||||
onChanged: (newValue) => settingsStore.show7TVEmotes = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Show BTTV emotes',
|
||||
value: settingsStore.showBTTVEmotes,
|
||||
onChanged: (newValue) => settingsStore.showBTTVEmotes = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Show BTTV badges',
|
||||
value: settingsStore.showBTTVBadges,
|
||||
onChanged: (newValue) => settingsStore.showBTTVBadges = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Show FFZ emotes',
|
||||
value: settingsStore.showFFZEmotes,
|
||||
onChanged: (newValue) => settingsStore.showFFZEmotes = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Show FFZ badges',
|
||||
value: settingsStore.showFFZBadges,
|
||||
onChanged: (newValue) => settingsStore.showFFZBadges = newValue,
|
||||
),
|
||||
const SectionHeader('Recent messages'),
|
||||
SettingsListSwitch(
|
||||
title: 'Show historical recent messages',
|
||||
subtitle: Text.rich(
|
||||
TextSpan(
|
||||
text:
|
||||
'Loads historical recent messages in chat through a third-party API service at ',
|
||||
children: [
|
||||
TextSpan(
|
||||
text: 'https://recent-messages.robotty.de/',
|
||||
style: TextStyle(
|
||||
color: context.colorScheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: context.colorScheme.primary,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () => launchUrl(
|
||||
Uri.parse('https://recent-messages.robotty.de/'),
|
||||
mode: settingsStore.launchUrlExternal
|
||||
? LaunchMode.externalApplication
|
||||
: LaunchMode.inAppBrowserView,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
value: settingsStore.showRecentMessages,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.showRecentMessages = newValue,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:frosty/constants.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
import 'package:frosty/screens/settings/widgets/settings_list_select.dart';
|
||||
import 'package:frosty/screens/settings/widgets/settings_list_slider.dart';
|
||||
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
|
||||
import 'package:frosty/screens/settings/widgets/settings_muted_words.dart';
|
||||
import 'package:frosty/utils/context_extensions.dart';
|
||||
import 'package:frosty/widgets/frosty_cached_network_image.dart';
|
||||
import 'package:frosty/widgets/section_header.dart';
|
||||
import 'package:frosty/widgets/settings_page_layout.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class ChatSettings extends StatelessWidget {
|
||||
final SettingsStore settingsStore;
|
||||
|
||||
const ChatSettings({super.key, required this.settingsStore});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Observer(
|
||||
builder: (context) => SettingsPageLayout(
|
||||
children: [
|
||||
const SectionHeader('General', isFirst: true),
|
||||
SettingsListSwitch(
|
||||
title: 'Keep screen on',
|
||||
subtitle: const Text(
|
||||
'Prevents the screen from sleeping while a channel is open.',
|
||||
),
|
||||
value: settingsStore.keepScreenAwake,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.keepScreenAwake = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Autocomplete',
|
||||
subtitle: const Text(
|
||||
'Shows matching emotes and mentions while typing.',
|
||||
),
|
||||
value: settingsStore.autocomplete,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.autocomplete = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Load recent messages',
|
||||
subtitle: Text.rich(
|
||||
TextSpan(
|
||||
text:
|
||||
'Loads historical recent messages in chat through a third-party API service at ',
|
||||
children: [
|
||||
TextSpan(
|
||||
text: 'https://recent-messages.robotty.de/',
|
||||
style: TextStyle(
|
||||
color: context.colorScheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: context.colorScheme.primary,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () => launchUrl(
|
||||
Uri.parse(
|
||||
'https://recent-messages.robotty.de/',
|
||||
),
|
||||
mode: settingsStore.launchUrlExternal
|
||||
? LaunchMode.externalApplication
|
||||
: LaunchMode.inAppBrowserView,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
value: settingsStore.showRecentMessages,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.showRecentMessages = newValue,
|
||||
),
|
||||
const SectionHeader('Message sizing'),
|
||||
ExpansionTile(
|
||||
title: const Text('Preview'),
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: DefaultTextStyle(
|
||||
style: DefaultTextStyle.of(
|
||||
context,
|
||||
).style.copyWith(fontSize: settingsStore.fontSize),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: FrostyCachedNetworkImage(
|
||||
imageUrl:
|
||||
'https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/3',
|
||||
height:
|
||||
defaultBadgeSize *
|
||||
settingsStore.badgeScale,
|
||||
width:
|
||||
defaultBadgeSize *
|
||||
settingsStore.badgeScale,
|
||||
),
|
||||
),
|
||||
const TextSpan(
|
||||
text: ' Badge and emote preview. ',
|
||||
),
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: FrostyCachedNetworkImage(
|
||||
imageUrl:
|
||||
'https://static-cdn.jtvnw.net/emoticons/v2/425618/default/dark/3.0',
|
||||
height:
|
||||
defaultEmoteSize *
|
||||
settingsStore.emoteScale,
|
||||
width:
|
||||
defaultEmoteSize *
|
||||
settingsStore.emoteScale,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
textScaler: settingsStore.messageScale.textScaler,
|
||||
),
|
||||
SizedBox(height: settingsStore.messageSpacing),
|
||||
Text(
|
||||
'Hello! Here\'s a text preview.',
|
||||
textScaler: settingsStore.messageScale.textScaler,
|
||||
),
|
||||
SizedBox(height: settingsStore.messageSpacing),
|
||||
Text(
|
||||
'And another for spacing without an emote!',
|
||||
textScaler: settingsStore.messageScale.textScaler,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsListSlider(
|
||||
title: 'Badge scale',
|
||||
trailing: '${settingsStore.badgeScale.toStringAsFixed(2)}x',
|
||||
value: settingsStore.badgeScale,
|
||||
min: 0.25,
|
||||
max: 3.0,
|
||||
divisions: 11,
|
||||
onChanged: (newValue) => settingsStore.badgeScale = newValue,
|
||||
),
|
||||
SettingsListSlider(
|
||||
title: 'Emote scale',
|
||||
trailing: '${settingsStore.emoteScale.toStringAsFixed(2)}x',
|
||||
value: settingsStore.emoteScale,
|
||||
min: 0.25,
|
||||
max: 3.0,
|
||||
divisions: 11,
|
||||
onChanged: (newValue) => settingsStore.emoteScale = newValue,
|
||||
),
|
||||
SettingsListSlider(
|
||||
title: 'Message scale',
|
||||
trailing: '${settingsStore.messageScale.toStringAsFixed(2)}x',
|
||||
value: settingsStore.messageScale,
|
||||
min: 0.5,
|
||||
max: 2.0,
|
||||
divisions: 6,
|
||||
onChanged: (newValue) => settingsStore.messageScale = newValue,
|
||||
),
|
||||
SettingsListSlider(
|
||||
title: 'Message spacing',
|
||||
trailing:
|
||||
'${settingsStore.messageSpacing.toStringAsFixed(0)}px',
|
||||
value: settingsStore.messageSpacing,
|
||||
max: 30.0,
|
||||
divisions: 15,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.messageSpacing = newValue,
|
||||
),
|
||||
SettingsListSlider(
|
||||
title: 'Font size',
|
||||
trailing: settingsStore.fontSize.toInt().toString(),
|
||||
value: settingsStore.fontSize,
|
||||
min: 5,
|
||||
max: 20,
|
||||
divisions: 15,
|
||||
onChanged: (newValue) => settingsStore.fontSize = newValue,
|
||||
),
|
||||
const SectionHeader('Message appearance'),
|
||||
SettingsListSwitch(
|
||||
title: 'Show deleted messages',
|
||||
subtitle: const Text(
|
||||
'Restores the original message of deleted messages.',
|
||||
),
|
||||
value: settingsStore.showDeletedMessages,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.showDeletedMessages = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Show message dividers',
|
||||
value: settingsStore.showChatMessageDividers,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.showChatMessageDividers = newValue,
|
||||
),
|
||||
SettingsListSelect(
|
||||
title: 'Timestamps',
|
||||
selectedOption:
|
||||
timestampNames[settingsStore.timestampType.index],
|
||||
options: timestampNames,
|
||||
onChanged: (newValue) => settingsStore.timestampType =
|
||||
TimestampType.values[timestampNames.indexOf(newValue)],
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<void> _extractGqlToken() async {
|
||||
try {
|
||||
final token = await CookieExtractor.extractTwitchAuthToken();
|
||||
if (token != null) {
|
||||
runInAction(() {
|
||||
_gqlToken = token;
|
||||
});
|
||||
await _storage.write(key: _gqlTokenKey, value: token);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('GQL token extraction failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize by retrieving a token if it does not already exist.
|
||||
@action
|
||||
Future<void> init() async {
|
||||
try {
|
||||
// Read and set the currently stored user token, if any.
|
||||
_token = await _storage.read(key: _userTokenKey);
|
||||
_gqlToken = await _storage.read(key: _gqlTokenKey);
|
||||
|
||||
// If the token does not exist, get the default token.
|
||||
// Otherwise, log in.
|
||||
|
|
@ -293,9 +317,11 @@ abstract class AuthBase with Store {
|
|||
Future<void> logout() async {
|
||||
try {
|
||||
_stopReconnectLoop();
|
||||
// Delete the existing user token.
|
||||
// Delete the existing user token and GQL token.
|
||||
await _storage.delete(key: _userTokenKey);
|
||||
await _storage.delete(key: _gqlTokenKey);
|
||||
_token = null;
|
||||
_gqlToken = null;
|
||||
|
||||
// Clear the user info.
|
||||
user.dispose();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = <String>[];
|
||||
static const defaultMatchWholeWord = true;
|
||||
|
||||
// Autocomplete defaults
|
||||
static const defaultAutocomplete = true;
|
||||
|
||||
// Emotes and badges defaults
|
||||
// -- Emotes & Badges --
|
||||
static const defaultShowTwitchEmotes = true;
|
||||
static const defaultShowTwitchBadges = true;
|
||||
static const defaultShow7TVEmotes = true;
|
||||
|
|
@ -161,14 +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 = <PersistedChatTab>[];
|
||||
static const defaultLandscapeChatLeftSide = false;
|
||||
static const defaultLandscapeForceVerticalChat = false;
|
||||
static const defaultLandscapeCutout = LandscapeCutoutType.none;
|
||||
static const defaultChatWidth = 0.2;
|
||||
static const defaultFullScreenChatOverlayOpacity = 0.5;
|
||||
|
||||
// Message sizing options
|
||||
// -- Behavior --
|
||||
static const defaultKeepScreenAwake = true;
|
||||
static const defaultAutocomplete = true;
|
||||
static const defaultShowRecentMessages = false;
|
||||
static const defaultAutoSyncChatDelay = false;
|
||||
static const defaultChatDelay = 0.0;
|
||||
static const defaultMutedWords = <String>[];
|
||||
static const defaultMatchWholeWord = true;
|
||||
|
||||
// Messages options
|
||||
@JsonKey(defaultValue: defaultBadgeScale)
|
||||
@observable
|
||||
var badgeScale = defaultBadgeScale;
|
||||
|
|
@ -189,7 +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<String> 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
|
||||
|
|
|
|||
|
|
@ -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<dynamic>?)
|
||||
?.map((e) => PersistedChatTab.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[]
|
||||
..landscapeChatLeftSide = json['landscapeChatLeftSide'] as bool? ?? false
|
||||
..landscapeForceVerticalChat =
|
||||
json['landscapeForceVerticalChat'] as bool? ?? false
|
||||
|
|
@ -59,21 +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<dynamic>?)
|
||||
?.map((e) => PersistedChatTab.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[]
|
||||
..autoSyncChatDelay = json['autoSyncChatDelay'] as bool? ?? false
|
||||
..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0
|
||||
..mutedWords =
|
||||
(json['mutedWords'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
|
|
@ -101,7 +102,7 @@ Map<String, dynamic> _$SettingsStoreToJson(
|
|||
'showVideo': instance.showVideo,
|
||||
'defaultToHighestQuality': instance.defaultToHighestQuality,
|
||||
'useTextureRendering': instance.useTextureRendering,
|
||||
'keepScreenAwake': instance.keepScreenAwake,
|
||||
'useNativePlayer': instance.useNativePlayer,
|
||||
'showOverlay': instance.showOverlay,
|
||||
'toggleableOverlay': instance.toggleableOverlay,
|
||||
'showLatency': instance.showLatency,
|
||||
|
|
@ -113,17 +114,8 @@ Map<String, dynamic> _$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<String, dynamic> _$SettingsStoreToJson(
|
|||
'showBTTVBadges': instance.showBTTVBadges,
|
||||
'showFFZEmotes': instance.showFFZEmotes,
|
||||
'showFFZBadges': instance.showFFZBadges,
|
||||
'showRecentMessages': instance.showRecentMessages,
|
||||
'emoteMenuButtonOnLeft': instance.emoteMenuButtonOnLeft,
|
||||
'persistChatTabs': instance.persistChatTabs,
|
||||
'secondaryTabs': instance.secondaryTabs,
|
||||
'landscapeChatLeftSide': instance.landscapeChatLeftSide,
|
||||
'landscapeForceVerticalChat': instance.landscapeForceVerticalChat,
|
||||
'landscapeCutout': _$LandscapeCutoutTypeEnumMap[instance.landscapeCutout]!,
|
||||
'chatWidth': instance.chatWidth,
|
||||
'fullScreenChatOverlayOpacity': instance.fullScreenChatOverlayOpacity,
|
||||
'keepScreenAwake': instance.keepScreenAwake,
|
||||
'autocomplete': instance.autocomplete,
|
||||
'showRecentMessages': instance.showRecentMessages,
|
||||
'autoSyncChatDelay': instance.autoSyncChatDelay,
|
||||
'chatDelay': instance.chatDelay,
|
||||
'mutedWords': instance.mutedWords,
|
||||
'matchWholeWord': instance.matchWholeWord,
|
||||
'shareCrashLogsAndAnalytics': instance.shareCrashLogsAndAnalytics,
|
||||
|
|
@ -325,21 +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},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
9
lib/services/cookie_extractor.dart
Normal file
9
lib/services/cookie_extractor.dart
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import 'package:flutter/services.dart';
|
||||
|
||||
class CookieExtractor {
|
||||
static const _channel = MethodChannel('frosty/cookie_extractor');
|
||||
|
||||
static Future<String?> extractTwitchAuthToken() async {
|
||||
return await _channel.invokeMethod<String>('extractTwitchAuthToken');
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<TwitchApi>();
|
||||
|
||||
/// Gets TwitchGqlApi from provider
|
||||
TwitchGqlApi get twitchGqlApi => read<TwitchGqlApi>();
|
||||
|
||||
/// Gets AuthStore from provider
|
||||
AuthStore get authStore => read<AuthStore>();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ class _SettingsPageLayoutState extends State<SettingsPageLayout> {
|
|||
|
||||
// Responsive values based on orientation
|
||||
final listPadding = EdgeInsets.only(
|
||||
top: isPortrait ? 116 : 92,
|
||||
top: isPortrait ? 124 : 100,
|
||||
bottom: widget.hasBottomPadding ? context.safePaddingBottom + 8 : 0,
|
||||
);
|
||||
|
||||
|
|
|
|||
41
pubspec.lock
41
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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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': <Map<String, dynamic>>[],
|
||||
'pagination': <String, String>{},
|
||||
}),
|
||||
queryParameters: {
|
||||
'user_id': '111',
|
||||
'first': '20',
|
||||
'type': 'archive',
|
||||
'after': 'page2',
|
||||
},
|
||||
);
|
||||
|
||||
final videos = await api.getVideos(userId: '111', cursor: 'page2');
|
||||
|
||||
expect(videos.data, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
208
test/apis/twitch_gql_api_test.dart
Normal file
208
test/apis/twitch_gql_api_test.dart
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||
import 'package:frosty/constants.dart';
|
||||
import 'package:http_mock_adapter/http_mock_adapter.dart';
|
||||
|
||||
void main() {
|
||||
late Dio dio;
|
||||
late DioAdapter dioAdapter;
|
||||
late TwitchGqlApi api;
|
||||
|
||||
setUp(() {
|
||||
dio = Dio(BaseOptions());
|
||||
dioAdapter = DioAdapter(dio: dio);
|
||||
api = TwitchGqlApi(dio);
|
||||
});
|
||||
|
||||
group('getVideoComments', () {
|
||||
test('fetches comments by content offset', () async {
|
||||
dioAdapter.onPost(
|
||||
'https://gql.twitch.tv/gql',
|
||||
(server) => server.reply(200, {
|
||||
'data': {
|
||||
'video': {
|
||||
'comments': {
|
||||
'edges': [
|
||||
{
|
||||
'node': {
|
||||
'id': 'c1',
|
||||
'contentOffsetSeconds': 10,
|
||||
'commenter': {
|
||||
'id': 'u1',
|
||||
'displayName': 'User1',
|
||||
'login': 'user1',
|
||||
},
|
||||
'message': {
|
||||
'userColor': '#FF0000',
|
||||
'userBadges': <Map<String, dynamic>>[],
|
||||
'fragments': [
|
||||
{'text': 'hello', 'emote': null},
|
||||
],
|
||||
},
|
||||
},
|
||||
'cursor': 'cursor-1',
|
||||
},
|
||||
],
|
||||
'pageInfo': {'hasNextPage': true},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
data: {
|
||||
'operationName': 'VideoCommentsByOffsetOrCursor',
|
||||
'variables': {
|
||||
'videoID': 'v123',
|
||||
'contentOffsetSeconds': 0,
|
||||
},
|
||||
'extensions': {
|
||||
'persistedQuery': {
|
||||
'version': 1,
|
||||
'sha256Hash':
|
||||
'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a',
|
||||
},
|
||||
},
|
||||
},
|
||||
headers: {'Client-ID': twitchGqlMobileClientId},
|
||||
);
|
||||
|
||||
final response = await api.getVideoComments(
|
||||
videoId: 'v123',
|
||||
contentOffsetSeconds: 0,
|
||||
);
|
||||
|
||||
expect(response.comments, hasLength(1));
|
||||
expect(response.comments.first.id, 'c1');
|
||||
expect(response.comments.first.commenterLogin, 'user1');
|
||||
expect(response.hasNextPage, isTrue);
|
||||
expect(response.cursor, 'cursor-1');
|
||||
});
|
||||
|
||||
test('fetches comments by cursor', () async {
|
||||
dioAdapter.onPost(
|
||||
'https://gql.twitch.tv/gql',
|
||||
(server) => server.reply(200, {
|
||||
'data': {
|
||||
'video': {
|
||||
'comments': {
|
||||
'edges': [
|
||||
{
|
||||
'node': {
|
||||
'id': 'c2',
|
||||
'contentOffsetSeconds': 20,
|
||||
'commenter': {
|
||||
'id': 'u2',
|
||||
'displayName': 'User2',
|
||||
'login': 'user2',
|
||||
},
|
||||
'message': {
|
||||
'userColor': null,
|
||||
'userBadges': <Map<String, dynamic>>[],
|
||||
'fragments': [
|
||||
{'text': 'world', 'emote': null},
|
||||
],
|
||||
},
|
||||
},
|
||||
'cursor': 'cursor-2',
|
||||
},
|
||||
],
|
||||
'pageInfo': {'hasNextPage': false},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
data: {
|
||||
'operationName': 'VideoCommentsByOffsetOrCursor',
|
||||
'variables': {
|
||||
'videoID': 'v123',
|
||||
'cursor': 'cursor-1',
|
||||
},
|
||||
'extensions': {
|
||||
'persistedQuery': {
|
||||
'version': 1,
|
||||
'sha256Hash':
|
||||
'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a',
|
||||
},
|
||||
},
|
||||
},
|
||||
headers: {'Client-ID': twitchGqlMobileClientId},
|
||||
);
|
||||
|
||||
final response = await api.getVideoComments(
|
||||
videoId: 'v123',
|
||||
cursor: 'cursor-1',
|
||||
);
|
||||
|
||||
expect(response.comments, hasLength(1));
|
||||
expect(response.comments.first.id, 'c2');
|
||||
expect(response.hasNextPage, isFalse);
|
||||
});
|
||||
|
||||
test('handles null video (VOD not found)', () async {
|
||||
dioAdapter.onPost(
|
||||
'https://gql.twitch.tv/gql',
|
||||
(server) => server.reply(200, {
|
||||
'data': {'video': null},
|
||||
}),
|
||||
data: {
|
||||
'operationName': 'VideoCommentsByOffsetOrCursor',
|
||||
'variables': {
|
||||
'videoID': 'nonexistent',
|
||||
'contentOffsetSeconds': 0,
|
||||
},
|
||||
'extensions': {
|
||||
'persistedQuery': {
|
||||
'version': 1,
|
||||
'sha256Hash':
|
||||
'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a',
|
||||
},
|
||||
},
|
||||
},
|
||||
headers: {'Client-ID': twitchGqlMobileClientId},
|
||||
);
|
||||
|
||||
final response = await api.getVideoComments(
|
||||
videoId: 'nonexistent',
|
||||
contentOffsetSeconds: 0,
|
||||
);
|
||||
|
||||
expect(response.comments, isEmpty);
|
||||
expect(response.hasNextPage, isFalse);
|
||||
});
|
||||
|
||||
test('throws on GQL errors', () async {
|
||||
dioAdapter.onPost(
|
||||
'https://gql.twitch.tv/gql',
|
||||
(server) => server.reply(200, {
|
||||
'errors': [
|
||||
{'message': 'Something went wrong'},
|
||||
],
|
||||
'data': null,
|
||||
}),
|
||||
data: {
|
||||
'operationName': 'VideoCommentsByOffsetOrCursor',
|
||||
'variables': {
|
||||
'videoID': 'v123',
|
||||
'contentOffsetSeconds': 0,
|
||||
},
|
||||
'extensions': {
|
||||
'persistedQuery': {
|
||||
'version': 1,
|
||||
'sha256Hash':
|
||||
'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a',
|
||||
},
|
||||
},
|
||||
},
|
||||
headers: {'Client-ID': twitchGqlMobileClientId},
|
||||
);
|
||||
|
||||
expect(
|
||||
() => api.getVideoComments(
|
||||
videoId: 'v123',
|
||||
contentOffsetSeconds: 0,
|
||||
),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
216
test/models/gql_chat_comment_test.dart
Normal file
216
test/models/gql_chat_comment_test.dart
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frosty/models/gql_chat_comment.dart';
|
||||
import 'package:frosty/models/irc.dart';
|
||||
|
||||
void main() {
|
||||
group('GqlChatComment', () {
|
||||
test('fromGqlNode parses a complete comment', () {
|
||||
final node = {
|
||||
'id': 'comment-1',
|
||||
'contentOffsetSeconds': 120,
|
||||
'commenter': {
|
||||
'id': 'user-123',
|
||||
'displayName': 'TestUser',
|
||||
'login': 'testuser',
|
||||
},
|
||||
'message': {
|
||||
'userColor': '#FF0000',
|
||||
'userBadges': [
|
||||
{'setID': 'subscriber', 'version': '12'},
|
||||
],
|
||||
'fragments': [
|
||||
{'text': 'Hello world ', 'emote': null},
|
||||
{
|
||||
'text': 'Kappa',
|
||||
'emote': {'emoteID': '25'},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
final comment = GqlChatComment.fromGqlNode(node);
|
||||
|
||||
expect(comment.id, 'comment-1');
|
||||
expect(comment.contentOffsetSeconds, 120);
|
||||
expect(comment.commenterLogin, 'testuser');
|
||||
expect(comment.commenterDisplayName, 'TestUser');
|
||||
expect(comment.commenterId, 'user-123');
|
||||
expect(comment.userColor, '#FF0000');
|
||||
expect(comment.userBadges.length, 1);
|
||||
expect(comment.userBadges.first.setId, 'subscriber');
|
||||
expect(comment.userBadges.first.version, '12');
|
||||
expect(comment.messageFragments.length, 2);
|
||||
expect(comment.messageFragments[0].text, 'Hello world ');
|
||||
expect(comment.messageFragments[0].emote, isNull);
|
||||
expect(comment.messageFragments[1].text, 'Kappa');
|
||||
expect(comment.messageFragments[1].emote!.emoteId, '25');
|
||||
});
|
||||
|
||||
test('fromGqlNode handles deleted commenter (null)', () {
|
||||
final node = {
|
||||
'id': 'comment-2',
|
||||
'contentOffsetSeconds': 60,
|
||||
'commenter': null,
|
||||
'message': {
|
||||
'userColor': null,
|
||||
'userBadges': <Map<String, dynamic>>[],
|
||||
'fragments': [
|
||||
{'text': 'deleted message', 'emote': null},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
final comment = GqlChatComment.fromGqlNode(node);
|
||||
|
||||
expect(comment.commenterLogin, isNull);
|
||||
expect(comment.commenterDisplayName, isNull);
|
||||
expect(comment.commenterId, isNull);
|
||||
expect(comment.userColor, isNull);
|
||||
expect(comment.userBadges, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('GqlChatResponse', () {
|
||||
test('fromJson parses a valid response', () {
|
||||
final json = {
|
||||
'data': {
|
||||
'video': {
|
||||
'comments': {
|
||||
'edges': [
|
||||
{
|
||||
'node': {
|
||||
'id': 'c1',
|
||||
'contentOffsetSeconds': 10,
|
||||
'commenter': {
|
||||
'id': 'u1',
|
||||
'displayName': 'User1',
|
||||
'login': 'user1',
|
||||
},
|
||||
'message': {
|
||||
'userColor': '#00FF00',
|
||||
'userBadges': <Map<String, dynamic>>[],
|
||||
'fragments': [
|
||||
{'text': 'hello', 'emote': null},
|
||||
],
|
||||
},
|
||||
},
|
||||
'cursor': 'cursor-abc',
|
||||
},
|
||||
],
|
||||
'pageInfo': {'hasNextPage': true},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
final response = GqlChatResponse.fromJson(json);
|
||||
|
||||
expect(response.comments.length, 1);
|
||||
expect(response.comments.first.id, 'c1');
|
||||
expect(response.hasNextPage, isTrue);
|
||||
expect(response.cursor, 'cursor-abc');
|
||||
});
|
||||
|
||||
test('fromJson throws on GQL errors', () {
|
||||
final json = {
|
||||
'errors': [
|
||||
{'message': 'Something went wrong'},
|
||||
],
|
||||
'data': null,
|
||||
};
|
||||
|
||||
expect(
|
||||
() => GqlChatResponse.fromJson(json),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('fromJson handles null video (VOD not found)', () {
|
||||
final json = {
|
||||
'data': {'video': null},
|
||||
};
|
||||
|
||||
final response = GqlChatResponse.fromJson(json);
|
||||
expect(response.comments, isEmpty);
|
||||
expect(response.hasNextPage, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('IRCMessage.fromGqlComment', () {
|
||||
test('converts comment to IRCMessage with correct tags', () {
|
||||
final comment = GqlChatComment(
|
||||
id: 'msg-1',
|
||||
contentOffsetSeconds: 300,
|
||||
commenterLogin: 'testuser',
|
||||
commenterDisplayName: 'TestUser',
|
||||
commenterId: 'user-123',
|
||||
userColor: '#FF0000',
|
||||
userBadges: [
|
||||
const GqlChatBadge(setId: 'subscriber', version: '3'),
|
||||
const GqlChatBadge(setId: 'moderator', version: '1'),
|
||||
],
|
||||
messageFragments: [
|
||||
const GqlChatFragment(text: 'Hello world'),
|
||||
],
|
||||
);
|
||||
|
||||
final irc = IRCMessage.fromGqlComment(comment);
|
||||
|
||||
expect(irc.command, Command.privateMessage);
|
||||
expect(irc.user, 'testuser');
|
||||
expect(irc.tags['display-name'], 'TestUser');
|
||||
expect(irc.tags['user-id'], 'user-123');
|
||||
expect(irc.tags['id'], 'msg-1');
|
||||
expect(irc.tags['color'], '#FF0000');
|
||||
expect(irc.tags['badges'], 'subscriber/3,moderator/1');
|
||||
expect(irc.tags['tmi-sent-ts'], '300000');
|
||||
expect(irc.message, 'Hello world');
|
||||
expect(irc.split, ['Hello', 'world']);
|
||||
expect(irc.localEmotes, isNull);
|
||||
});
|
||||
|
||||
test('populates localEmotes for Twitch emotes', () {
|
||||
final comment = GqlChatComment(
|
||||
id: 'msg-2',
|
||||
contentOffsetSeconds: 10,
|
||||
commenterLogin: 'user',
|
||||
commenterDisplayName: 'User',
|
||||
commenterId: 'u1',
|
||||
userBadges: [],
|
||||
messageFragments: [
|
||||
const GqlChatFragment(text: 'Hey '),
|
||||
const GqlChatFragment(
|
||||
text: 'Kappa',
|
||||
emote: GqlChatEmote(emoteId: '25'),
|
||||
),
|
||||
const GqlChatFragment(text: ' nice'),
|
||||
],
|
||||
);
|
||||
|
||||
final irc = IRCMessage.fromGqlComment(comment);
|
||||
|
||||
expect(irc.message, 'Hey Kappa nice');
|
||||
expect(irc.localEmotes, isNotNull);
|
||||
expect(irc.localEmotes!['Kappa'], isNotNull);
|
||||
expect(irc.localEmotes!['Kappa']!.name, 'Kappa');
|
||||
expect(irc.localEmotes!['Kappa']!.url, contains('25'));
|
||||
});
|
||||
|
||||
test('handles deleted commenter', () {
|
||||
final comment = GqlChatComment(
|
||||
id: 'msg-3',
|
||||
contentOffsetSeconds: 0,
|
||||
userBadges: [],
|
||||
messageFragments: [
|
||||
const GqlChatFragment(text: 'deleted message'),
|
||||
],
|
||||
);
|
||||
|
||||
final irc = IRCMessage.fromGqlComment(comment);
|
||||
|
||||
expect(irc.tags['display-name'], 'Deleted User');
|
||||
expect(irc.user, isNull);
|
||||
expect(irc.tags['user-id'], '');
|
||||
});
|
||||
});
|
||||
}
|
||||
128
test/models/video_test.dart
Normal file
128
test/models/video_test.dart
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frosty/models/video.dart';
|
||||
|
||||
void main() {
|
||||
group('VideoTwitch', () {
|
||||
test('fromJson parses all fields correctly', () {
|
||||
final json = {
|
||||
'id': '12345',
|
||||
'stream_id': '67890',
|
||||
'user_id': '111',
|
||||
'user_login': 'testuser',
|
||||
'user_name': 'TestUser',
|
||||
'title': 'Test Stream VOD',
|
||||
'created_at': '2024-01-15T10:30:00Z',
|
||||
'published_at': '2024-01-15T10:30:00Z',
|
||||
'duration': '3h26m15s',
|
||||
'thumbnail_url':
|
||||
'https://static-cdn.jtvnw.net/cf_vods/abc/%{width}x%{height}.jpg',
|
||||
'view_count': 12345,
|
||||
};
|
||||
|
||||
final video = VideoTwitch.fromJson(json);
|
||||
|
||||
expect(video.id, '12345');
|
||||
expect(video.streamId, '67890');
|
||||
expect(video.userId, '111');
|
||||
expect(video.userLogin, 'testuser');
|
||||
expect(video.userName, 'TestUser');
|
||||
expect(video.title, 'Test Stream VOD');
|
||||
expect(video.createdAt, '2024-01-15T10:30:00Z');
|
||||
expect(video.publishedAt, '2024-01-15T10:30:00Z');
|
||||
expect(video.duration, '3h26m15s');
|
||||
expect(video.thumbnailUrl, contains('%{width}'));
|
||||
expect(video.viewCount, 12345);
|
||||
});
|
||||
|
||||
test('fromJson handles null streamId', () {
|
||||
final json = {
|
||||
'id': '12345',
|
||||
'stream_id': null,
|
||||
'user_id': '111',
|
||||
'user_login': 'testuser',
|
||||
'user_name': 'TestUser',
|
||||
'title': 'Test',
|
||||
'created_at': '2024-01-15T10:30:00Z',
|
||||
'published_at': '2024-01-15T10:30:00Z',
|
||||
'duration': '1h0m0s',
|
||||
'thumbnail_url': '',
|
||||
'view_count': 0,
|
||||
};
|
||||
|
||||
final video = VideoTwitch.fromJson(json);
|
||||
expect(video.streamId, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('VideosTwitch', () {
|
||||
test('fromJson parses data list and pagination', () {
|
||||
final json = {
|
||||
'data': [
|
||||
{
|
||||
'id': '1',
|
||||
'stream_id': null,
|
||||
'user_id': '111',
|
||||
'user_login': 'user',
|
||||
'user_name': 'User',
|
||||
'title': 'Title',
|
||||
'created_at': '2024-01-01T00:00:00Z',
|
||||
'published_at': '2024-01-01T00:00:00Z',
|
||||
'duration': '1h0m0s',
|
||||
'thumbnail_url': '',
|
||||
'view_count': 100,
|
||||
},
|
||||
],
|
||||
'pagination': {'cursor': 'abc123'},
|
||||
};
|
||||
|
||||
final videos = VideosTwitch.fromJson(json);
|
||||
expect(videos.data.length, 1);
|
||||
expect(videos.data.first.id, '1');
|
||||
expect(videos.pagination['cursor'], 'abc123');
|
||||
});
|
||||
|
||||
test('fromJson handles empty data', () {
|
||||
final json = {
|
||||
'data': <Map<String, dynamic>>[],
|
||||
'pagination': <String, String>{},
|
||||
};
|
||||
|
||||
final videos = VideosTwitch.fromJson(json);
|
||||
expect(videos.data, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('parseTwitchDuration', () {
|
||||
test('parses hours, minutes, and seconds', () {
|
||||
expect(
|
||||
parseTwitchDuration('3h26m15s'),
|
||||
const Duration(hours: 3, minutes: 26, seconds: 15),
|
||||
);
|
||||
});
|
||||
|
||||
test('parses minutes and seconds only', () {
|
||||
expect(
|
||||
parseTwitchDuration('45m30s'),
|
||||
const Duration(minutes: 45, seconds: 30),
|
||||
);
|
||||
});
|
||||
|
||||
test('parses seconds only', () {
|
||||
expect(
|
||||
parseTwitchDuration('30s'),
|
||||
const Duration(seconds: 30),
|
||||
);
|
||||
});
|
||||
|
||||
test('parses hours and minutes only', () {
|
||||
expect(
|
||||
parseTwitchDuration('2h10m'),
|
||||
const Duration(hours: 2, minutes: 10),
|
||||
);
|
||||
});
|
||||
|
||||
test('returns Duration.zero for empty string', () {
|
||||
expect(parseTwitchDuration(''), Duration.zero);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue